Learn Zig Series (#180) - Mini Project: Synth Engine - Part 3
Learn Zig Series (#180) - Mini Project: Synth Engine - Part 3
What will I learn
- Why the per-sample
@sinwas the engine's hot spot, and how a wavetable oscillator turns a transcendental call into an array read plus a lerp; - How to build that one-cycle table at comptime so it costs nothing at runtime and lives in read-only memory;
- How a filter envelope sweeps the cutoff over the life of a note -- the classic subtractive-synth wow -- and the honest cost of updating a coefficient per sample;
- How a low-frequency oscillator adds vibrato, and why the same sine machinery does double duty as a modulator;
- How a soft clip on the master bus keeps a fat chord from clipping harshly, trading digital nastiness for analog-style warmth;
- How to render a whole phrase into a buffer and pin every upgrade down with tests, no soundcard required -- plus where C, Rust and Go land on the same polish.
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;
- Parts 1 and 2 of this project (episodes 178 and 179): the
Sampletype,noteToHz,Oscillator,Envelope, theOnePolefilter,centsToRatio, theVoice, and the voice-stealingSynthpool. Part 3 builds straight on top; - The audio foundations from episodes 169 to 174 (oscillators, envelopes, filters, mixing) will not hurt either;
- 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
- Learn Zig Series (#174) - MIDI Parsing and Generation
- Learn Zig Series (#175) - Mini Project: Pixel Art Editor - Part 1
- Learn Zig Series (#176) - Mini Project: Pixel Art Editor - Part 2
- Learn Zig Series (#177) - Mini Project: Pixel Art Editor - Part 3
- Learn Zig Series (#178) - Mini Project: Synth Engine - Part 1
- Learn Zig Series (#179) - Mini Project: Synth Engine - Part 2
- Learn Zig Series (#180) - Mini Project: Synth Engine - Part 3 (this post)
Learn Zig Series (#180) - Mini Project: Synth Engine - Part 3
Two episodes in, we already have a synth that behaves like an instrument. Part 1 built the bones: a noteToHz bridge from MIDI numbers to frequencies, an Oscillator that is pure pitch, an Envelope that is pure shape, a Voice that multiplies the two, and a Synth that pools sixteen voices and never touches the heap once it is running. Part 2 made it musical -- voice stealing so a seventeenth note sacrifices a fading tail in stead of getting dropped, unison detune so each voice is a fat little bank of oscillators, a one-pole low-pass filter to tame the buzz, and velocity so a soft keypress actually sounds soft.
But I ended Part 2 with a short list of sins, and today we atone for them. The @sin inside every oscillator is the single most expensive thing in the whole engine. The filter cutoff sits frozen at one value for the life of a note, which is not how any expressive synth behaves. And there is no movement in the sound at all -- no vibrato, no slow shimmer, nothing that breathes. This is the polish part: we make it fast, we make it move, and we make sure a big chord never clips your ears off. Here we go!
The sine was the hot spot: a wavetable oscillator
Let us start with the money problem. At 44,100 samples a second, sixteen voices, three oscillators each, that inner loop can call @sin well over two million times per second. A sine is a transcendental function -- the CPU computes it with a polynomial approximation, and while it is not slow in isolation, at that volume it dominates everything else the engine does combined.
The classic fix is older than digital synthesis is fashionable: a wavetable. Compute exactly one cycle of the waveform once, store it in an array, and then to produce a sample you just read the table at the current phase. A transcendental call becomes an array load. And because we read a fractional position between two table entries, we blend them with a little linear interpolation (a lerp) so the result stays smooth instead of stair-stepping.
Zig lets us build that table at comptime, which is the part I genuinely enjoy. The whole cycle is computed while the compiler runs, baked into the binary as read-only data, and costs precisely nothing when the program starts:
const std = @import("std");
const engine = @import("part2.zig"); // Sample, sample_rate, noteToHz, Envelope, OnePole, centsToRatio, unison
pub const Sample = engine.Sample;
pub const sample_rate = engine.sample_rate;
pub const table_len = 2048;
/// One cycle of a sine, computed at comptime into a fixed array. Reading it back
/// with interpolation replaces a per-sample @sin with an array load plus a lerp.
pub const sine_table: [table_len]f32 = blk: {
@setEvalBranchQuota(table_len * 8);
var t: [table_len]f32 = undefined;
var i: usize = 0;
while (i < table_len) : (i += 1) {
const phase = std.math.tau * @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(table_len));
t[i] = @sin(phase);
}
break :blk t;
};
That blk: { ... break :blk t; } is a labelled block used as an expression -- it runs the loop and yields the finished array as the value of sine_table. Because sine_table is a const at container scope, all of that runs at comptime; the @setEvalBranchQuota just tells the compiler we are fine with it looping a couple of thousand times during evaluation. The reader pays for the table in compile time (a blink) and zero runtime.
Reading it back is the whole trick, and it is short:
/// Read `table` at a fractional phase in [0,1) with linear interpolation between
/// the two nearest entries. This is what replaces the transcendental call.
pub fn readTable(table: []const f32, phase: f32) f32 {
const scaled = phase * @as(f32, @floatFromInt(table.len));
const idx: usize = @intFromFloat(scaled);
const frac = scaled - @as(f32, @floatFromInt(idx));
const a = table[idx % table.len];
const b = table[(idx + 1) % table.len];
return a + (b - a) * frac; // lerp between the two nearest samples
}
The oscillator itself now holds nothing but a phase and a per-sample phase increment. No trig, no state beyond a single float:
pub const Oscillator = struct {
phase: f32 = 0.0, // 0..1 position within one cycle
inc: f32 = 0.0, // how far to advance per sample
pub fn setFrequency(self: *Oscillator, hz: f32) void {
self.inc = hz / sample_rate;
}
pub fn next(self: *Oscillator, table: []const f32) f32 {
const v = readTable(table, self.phase);
self.phase += self.inc;
if (self.phase >= 1.0) self.phase -= 1.0; // wrap one cycle
return v;
}
};
The lovely part is that passing table as a []const f32 slice means this oscillator is not hard-wired to sines. Feed it a table of one saw cycle, or a hand-drawn wavetable, and the same code plays it -- the waveform lives in the data, not the code. That is exactly the kind of separation the series has leaned on again and again.
Making the note move: a filter envelope
In Part 2 the filter cutoff was set once at note-on and then held there. Real subtractive synths do something far more alive: they run a second envelope whose only job is to sweep the cutoff. Strike a key and the filter snaps open and then settles back down, so a note starts bright and mellows as it sustains -- that unmistakable wow that defines the sound of a hundred records.
The beautiful thing is we already built the machine for this. The Envelope from Part 1 outputs a 0-to-1 contour over time; nothing about it says "amplitude". So a filter envelope is just a second Envelope instance whose output we map onto a cutoff range in stead of a volume. No new type needed:
// Inside render, per sample: base cutoff plus however far the filter envelope
// has opened, then re-derive the coefficient. env_amount is in Hz.
const cutoff = self.base_cutoff + self.env_amount * self.filter_env.next();
self.filter.setCutoff(cutoff);
const shaped = self.filter.process(raw);
Now, honesty time, because this is where Part 3 has to be careful. In Part 2 I made a point of computing the filter coefficient once in setCutoff, keeping the per-sample process a single cheap line. A filter envelope breaks that promise on purpose -- if the cutoff changes every sample, we call setCutoff (and its exp) every sample too, and exp is a transcendental just like sin. We just spent a section removing one transcendental from the hot loop and now we are adding another back. That is not a mistake; it is a trade, and the professional move is to know you are making it.
The standard way to buy most of the expressiveness back without the full cost is block processing: recompute the coefficient once every 32 or 64 samples in stead of every single one. The ear cannot hear the difference (a cutoff sweep is slow relative to audio rate), and you cut the exp count by more than an order of magnitude. For clarity I will keep the per-sample version in the code below, but the moment this engine met a real audio callback, block-rate modulation is the first optimisation I would reach for.
A slow hand on the pitch: the LFO and vibrato
A note that never wavers sounds synthetic. Add a low-frequency oscillator -- an LFO -- running at a few Hz, wire its output to pitch, and you get vibrato: that gentle, singing wobble a violinist adds with their wrist. Wire the same LFO to amplitude in stead and you get tremolo. The word "oscillator" is doing the same job here it did for audio, only now the frequency is below hearing and the output is a control signal, not a sound.
Which means -- again -- we reuse the machinery we just built. An LFO is a wavetable oscillator running at 5 Hz reading the same sine table:
/// A low-frequency oscillator. Identical sine machinery to an audio oscillator,
/// but running at a few Hz and used to modulate parameters, not to be heard.
pub const Lfo = struct {
phase: f32 = 0.0,
inc: f32 = 0.0,
pub fn setRate(self: *Lfo, hz: f32) void {
self.inc = hz / sample_rate;
}
pub fn next(self: *Lfo) f32 {
const v = readTable(&sine_table, self.phase);
self.phase += self.inc;
if (self.phase >= 1.0) self.phase -= 1.0;
return v; // ranges -1..1
}
};
To apply vibrato we nudge each oscillator's frequency up and down by a tiny factor driven by the LFO. Strictly, a musical pitch wobble should be exponential (cents), but for a swing of only a few cents a straight linear multiplier 1 + depth * lfo is indistinguishable to the ear and avoids a pow per sample -- another one of those small numeric choices with a real cost attached.
The voice, fully wired
Time to assemble. The Voice grows a filter envelope, a vibrato LFO, and it remembers the base frequency of each of its unison oscillators (so vibrato can modulate around it). I also introduce a small Patch struct -- in synth language a "patch" is a saved sound, the collection of settings that make a preset. Passing one Patch into start is tidier than a fistful of loose arguments:
pub const unison = engine.unison; // oscillators per voice, from Part 2
pub const Patch = struct {
detune_cents: f32 = 8.0,
cutoff_hz: f32 = 400.0, // where the filter rests
filter_env_amount: f32 = 3500.0, // how far the filter envelope opens it
vibrato_hz: f32 = 5.0,
vibrato_cents: f32 = 6.0,
};
pub const Voice = struct {
oscs: [unison]Oscillator = .{.{}} ** unison,
amp_env: engine.Envelope,
filter_env: engine.Envelope,
filter: engine.OnePole = .{},
vibrato: Lfo = .{},
base_freq: [unison]f32 = .{0.0} ** unison,
base_cutoff: f32 = 400.0,
env_amount: f32 = 3500.0,
vibrato_depth: f32 = 0.0,
note: u8 = 0,
velocity: f32 = 1.0,
started_at: u64 = 0,
pub fn init() Voice {
return .{
.amp_env = engine.Envelope.init(0.005, 0.10, 0.7, 0.20),
.filter_env = engine.Envelope.init(0.002, 0.25, 0.4, 0.30),
};
}
pub fn isFree(self: Voice) bool {
return !self.amp_env.isActive();
}
pub fn isReleasing(self: Voice) bool {
return self.amp_env.stage == .release;
}
pub fn start(self: *Voice, note: u8, vel: f32, when: u64, patch: Patch) void {
self.note = note;
self.velocity = vel;
self.started_at = when;
self.base_cutoff = patch.cutoff_hz;
self.env_amount = patch.filter_env_amount;
self.vibrato_depth = patch.vibrato_cents / 1200.0; // small linear swing
self.vibrato.setRate(patch.vibrato_hz);
const base = engine.noteToHz(note);
for (&self.oscs, 0..) |*osc, i| {
const offset = patch.detune_cents *
(@as(f32, @floatFromInt(i)) - @as(f32, @floatFromInt(unison - 1)) / 2.0);
self.base_freq[i] = base * engine.centsToRatio(offset);
osc.phase = 0.0;
}
self.amp_env.gateOn();
self.filter_env.gateOn();
}
pub fn stop(self: *Voice) void {
self.amp_env.gateOff();
self.filter_env.gateOff();
}
};
Notice the two envelopes have different shapes on purpose: the amplitude envelope has a gentle attack and long release, while the filter envelope snaps open fast (2 ms attack) and decays to a lower sustain -- that difference is precisely what makes the wow audible. And rendering the voice ties every one of today's upgrades together in one honest function:
pub fn render(self: *Voice) Sample {
if (!self.amp_env.isActive()) return 0.0;
// Vibrato: a slow pitch wobble shared by the whole oscillator bank.
const wob = 1.0 + self.vibrato_depth * self.vibrato.next();
var raw: f32 = 0.0;
for (&self.oscs, 0..) |*osc, i| {
osc.setFrequency(self.base_freq[i] * wob);
raw += osc.next(&sine_table);
}
raw *= 1.0 / @as(f32, @floatFromInt(unison)); // average -> keep headroom
// Filter envelope sweeps the cutoff, then shape and velocity.
const cutoff = self.base_cutoff + self.env_amount * self.filter_env.next();
self.filter.setCutoff(cutoff);
const shaped = self.filter.process(raw);
return shaped * self.amp_env.next() * self.velocity;
}
Read it top to bottom and the entire signal path is right there: modulate pitch, sum the detuned bank, average for headroom, sweep the filter, apply the amplitude shape, scale by velocity. Nothing hidden, no allocation, no dispatch. That is the whole reason I keep coming back to Zig for audio -- the code is the signal flow.
The master bus: a gentle soft clip
One last danger. Sum sixteen fat voices and, even with careful averaging, a big chord can briefly push past the [-1, 1] range. A naive hard clamp (@max/@min) chops the peaks flat, and flat tops are packed with harsh high harmonics -- it sounds like the ugly kind of digital distortion. The kinder fix is soft clipping: bend the peaks smoothly so loud material saturates gracefully, the way analog gear does. A cheap cubic curve does it without a single transcendental:
/// Cubic soft clip: gently saturates instead of chopping peaks flat. Well-behaved
/// in [-1, 1] and saturating smoothly beyond, so a hot mix warms rather than tears.
pub fn softClip(x: f32) f32 {
if (x <= -1.0) return -2.0 / 3.0;
if (x >= 1.0) return 2.0 / 3.0;
return x - (x * x * x) / 3.0;
}
The master render sums the voices, applies a little makeup gain, and runs each frame through the soft clip so the output can never explode:
pub fn render(self: *Synth, out: []Sample) void {
for (out) |*frame| {
var mix: f32 = 0.0;
for (&self.voices) |*v| mix += v.render();
frame.* = softClip(mix * self.makeup); // graceful ceiling
}
}
Playing a whole phrase
The reward for all this discipline is the same as in Parts 1 and 2: because the engine is a pure function of numbers, we can render real music into a buffer with no hardware attached. Here is a little C-major arpeggio, each note struck then released, the whole thing built on the voice-stealing Synth from Part 2 driving today's richer voices:
/// Render a short arpeggio straight into a float buffer. To actually hear it,
/// episode 170's audio device -- or a .wav writer -- takes this exact buffer.
pub fn renderDemo(buf: []Sample) void {
var synth = Synth.init();
synth.patch = .{}; // default patch: mellow cutoff, lively filter envelope
const notes = [_]u8{ 60, 64, 67, 72 }; // C E G C
const step = buf.len / notes.len;
var i: usize = 0;
for (notes, 0..) |note, n| {
synth.noteOn(note, 100);
const start = n * step;
// hold most of the step, release for the tail
synth.render(buf[start .. start + step * 3 / 4]);
synth.noteOff(note);
synth.render(buf[start + step * 3 / 4 .. start + step]);
i += 1;
}
}
Because notes overlap into their release tails, the arpeggio rings like a real one instead of clicking note-to-note -- and the voice-stealing logic from Part 2 quietly guarantees we never run out of room even if we got greedy with the tempo.
Testing the polish
Every upgrade today is a numeric promise, so every one earns a test that runs at CPU speed. First, the wavetable must genuinely approximate a sine -- if the lerp or the table were wrong, this catches it:
test "the wavetable tracks a real sine within interpolation error" {
var max_err: f32 = 0.0;
var i: usize = 0;
while (i < 1000) : (i += 1) {
const phase = @as(f32, @floatFromInt(i)) / 1000.0;
const got = readTable(&sine_table, phase);
const want = @sin(std.math.tau * phase);
max_err = @max(max_err, @abs(got - want));
}
try std.testing.expect(max_err < 0.01); // 2048-entry table + lerp is tight
}
Then the headline musical feature: a note with a filter envelope must start brighter (more high-frequency energy) than it ends, because the cutoff opens on attack and settles on sustain. We approximate "brightness" with the average absolute sample-to-sample difference -- a rough high-frequency meter:
test "the filter envelope makes a note start brighter than it sustains" {
var synth = Synth.init();
synth.patch = .{ .filter_env_amount = 5000.0 };
synth.noteOn(60, 120);
var buf: [8192]Sample = undefined;
synth.render(&buf);
const early = brightness(buf[0..2048]);
const late = brightness(buf[6144..8192]);
try std.testing.expect(early > late); // filter closed over the note's life
}
fn brightness(win: []const Sample) f32 {
var sum: f32 = 0.0;
var i: usize = 1;
while (i < win.len) : (i += 1) sum += @abs(win[i] - win[i - 1]);
return sum / @as(f32, @floatFromInt(win.len));
}
And the safety net: the soft clip must keep the master output bounded no matter how many voices pile on. Slam every voice and confirm nothing escapes:
test "soft clip keeps the master output bounded under a huge chord" {
var synth = Synth.init();
var n: u8 = 0;
while (n < Synth.max_voices) : (n += 1) synth.noteOn(48 + n, 127);
var buf: [4096]Sample = undefined;
synth.render(&buf);
for (buf) |s| try std.testing.expect(@abs(s) <= 1.0); // never explodes
}
Three tests, three of today's upgrades pinned down as executable promises. If a later refactor breaks the table, kills the filter sweep, or lets the master clip hard, one of these goes red before a single wrong sample reaches anybody's ears.
Performance: faster and slower at once
Here is the honest scorecard, because Part 3 pulled in two directions at the same time. We removed the biggest cost -- the per-sample @sin -- and replaced it with an array load and a lerp, which on any modern CPU is dramatically cheaper and, better still, branch-free and cache-friendly. That is the single biggest win in the whole three-parter.
But we also added a per-sample exp in the filter coefficient (from the filter envelope) and a per-sample LFO read. So the net is nuanced:
- Wavetable: a clear, large win. One
@sinper oscillator becomes two array reads and a lerp. Do the arithmetic across two-million-plus oscillator evaluations a second and it is enormous. - Filter envelope: the one regression, and it is fixable. As I flagged, updating the coefficient every sample re-introduces a transcendental. Block-rate modulation -- recompute the coefficient every 32-64 samples -- recovers almost all of it for no audible loss. This is the first thing I would do in production.
- This still screams SIMD. Summing an oscillator bank, or rendering four voices at once, is embarrassingly parallel -- the
@Vectorwork from episode 19 fits the master mix loop like a glove. - Idle voices stay free. The
if (!self.amp_env.isActive()) return 0.0short-circuit means a half-empty pool costs almost nothing; the worst case only bites on a genuinely huge chord.
The meta-lesson is pure scipio-and-Zig: you can see every one of these costs by reading the source, because nothing is hidden -- no allocations sneaking in, no virtual dispatch, no GC waking up mid-callback. The performance profile is exactly what the code says it is.
The same job in C, Rust and Go
The ideas today -- wavetables, envelopes-as-modulators, LFOs, soft clipping -- are language-neutral; the ergonomics are not. In C this is the native habitat of synthesis, and the comptime sine table would become either a static const array you generate with a separate script and paste in, or a table you fill in at startup with a runtime loop. Zig's blk: { ... } computed at comptime is genuinely nicer: the table is in the type system, guaranteed built, with no init step to forget.
In Rust, the shape is again strikingly close. A const fn can build the table at compile time much as Zig's comptime does (with a few more restrictions historically), and the Patch/Voice/Synth structs map over almost line for line. Where Rust asks more of you is the same place as in Part 2: handing out &mut references into the voice array while iterating it takes a little borrow-checker choreography that Zig simply does not impose (with the matching loss of the safety net -- that is always the trade).
In Go, you would write all of this clearly and get memory safety for free, and for the surrounding application -- the UI, the MIDI plumbing, file I/O, the wav writer -- Go is a joy. But the objection from the earlier parts only sharpens now that each sample does more work: the garbage collector can pause at the worst possible millisecond, and the audio callback has zero tolerance for that. Go around the synth, not inside the callback. Zig lands where it always does -- C's directness and predictability, Rust-style instincts about safety via optionals and exhaustive switches, and you still deciding where every byte and every cycle goes.
Where the synth lands, and where we go next
Step back and listen to the arc. Part 1 gave us a synth that worked. Part 2 gave us one that behaved like an instrument. Part 3 gave us one that sounds good: the oscillators read from a comptime wavetable instead of grinding through a sine every sample, a filter envelope makes every note open up and settle, an LFO breathes vibrato into the pitch, and a soft clip on the master bus keeps even a sixteen-voice chord warm instead of harsh. And the whole thing is still a pure, tested core -- render a phrase into an array, prove its properties against plain floats, and only at the very edge bolt on the real audio device from episode 170. That separation of a testable core from a noisy shell is, if you take one habit from this project, the one to keep.
There is always more sound to chase -- wavetables you draw yourself, a second modulation source, a real reverb, block-rate everything. But the engine is done, and I am satisfied (well -- as satiesfied as a self-confessed perfectionist ever gets ;-)). From here the series turns somewhere quite different: away from the comfort of a hosted operating system, its allocators and its threads, and down toward the bare metal -- code that runs with no OS underneath it at all, where every byte of RAM is counted and there is no std to lean on. Same language, radically tighter constraints. It is my favourite kind of Zig.
De groeten, en tot de volgende keer! ;-)