Learn Zig Series (#171) - Synthesis: Oscillators
What will I learn?
- What a phase accumulator really is, and why it is the single beating heart inside every oscillator you will ever write;
- How to generate the four classic synth waveforms -- sine, sawtooth, square and triangle -- from one tiny struct;
- Why the naive saw and square alias horribly, what aliasing even is, and how a cheap PolyBLEP correction tames it;
- How a wavetable oscillator trades a little memory for a lot of speed, and how comptime (episode 9) bakes the table at compile time;
- How to test an oscillator deterministically with no sound card in sight, and the honest performance rules for the audio hot path;
- How C, Rust and Go reach for the very same ideas, and where Zig's explicitness quietly pays 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 PCM buffer and phase-accumulator idea from episode 169, and the real-time callback plus lock-free ring buffer from episode 170;
- Comfort with structs, enums and tagged unions (episode 6), 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 (this post)
Learn Zig Series (#171) - Synthesis: Oscillators
Last episode we finally got sound out of the speaker in real time -- the device pulling on its own clock, our callback drained from a lock-free ring, a lonely 440 Hz sine humming away on the audio thread. I ended on a promise: once you can shape a wave's frequency and amplitude over time, and stack more than one of them together, a bare oscillator turns into something you can actually play. Today we build the first half of that -- the oscillator itself. Not one sine, but a little machine that can be a sine, a sawtooth, a square or a triangle, at any pitch, cheaply, and without the ugly digital crackle that trips up almost everyone the first time they try. Here we go!
But first, the three loose ends from the audio-output episode.
Solutions to Episode 170 Exercises
Exercise 1 -- frequency on the fly. The trap here is that beginners change the phase to change pitch, which makes the wave jump and click. The right knob is the phase increment: change how fast the phase advances, never the phase's current value, and the waveform stays perfectly continuous across the change. Because the producer thread writes it while the audio thread reads it, the increment has to be atomic (episode 30):
const std = @import("std");
/// A sine voice whose frequency can change atomically while the audio thread runs.
const SweepState = struct {
phase: f32 = 0.0,
increment: std.atomic.Value(f32) = std.atomic.Value(f32).init(0.0),
amplitude: f32 = 0.2,
};
fn incrementFor(freq: f32, sample_rate: u32) f32 {
return 2.0 * std.math.pi * freq / @as(f32, @floatFromInt(sample_rate));
}
/// Producer: sweep 220 -> 880 Hz over `seconds`, writing only the increment.
fn sweepFrequency(state: *SweepState, sample_rate: u32, seconds: f32) void {
const steps: usize = @intFromFloat(seconds * 100.0); // retune 100x per second
for (0..steps) |i| {
const frac = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(steps));
const freq = 220.0 + frac * (880.0 - 220.0);
state.increment.store(incrementFor(freq, sample_rate), .monotonic);
std.Thread.sleep(10 * std.time.ns_per_ms);
}
}
The callback does state.increment.load(.monotonic) once per block and advances phase by it every sample. Because only the rate ever changes, the wave never discontinues -- it just bends. That is the whole reason a real synth sounds smooth when you turn a pitch wheel in stead of stepping between notes.
Exercise 2 -- play a WAV through the device. This is the producer/consumer split from last episode, wired to a file source in stead of a generated tone. We convert each i16 to f32, push it into the ring, and back off when the ring is full. Detecting a sample-rate mismatch (rather than silently playing at the wrong pitch) is the honest minimum -- real resampling comes later:
const std = @import("std");
fn i16ToFloat(s: i16) f32 {
return @as(f32, @floatFromInt(s)) / 32768.0;
}
/// Stream a loaded 16-bit WAV into the player's ring on a producer thread.
fn feedWav(player: *Player, wav: LoadedWav) void {
if (wav.spec.sample_rate != 48_000) {
std.debug.print(
"warning: WAV is {d} Hz but device is 48000 Hz (no resample yet)\n",
.{wav.spec.sample_rate},
);
}
var n: usize = 0;
while (n < wav.samples.len) {
if (player.ring.push(i16ToFloat(wav.samples[n]))) {
n += 1;
} else {
std.Thread.sleep(1 * std.time.ns_per_ms); // ring full -> back off
}
}
}
Note the callback is completely unchanged from last episode -- it still only drains the ring. That is the payoff of the producer/consumer design: swapping a sine generator for a file reader touches only the producer, never the real-time thread.
Exercise 3 -- an underrun counter. The simplest glitch detector any audio engine has: bump an atomic counter every time the callback finds the ring empty and has to emit a silent sample. Read it after the run. Starve the producer deliberately and you watch the number climb:
const std = @import("std");
const CountingPlayer = struct {
ring: *RingBuffer(f32),
channels: u8,
underruns: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
fn callback(
device: ?*c.ma_device,
output: ?*anyopaque,
input: ?*const anyopaque,
frame_count: c.ma_uint32,
) callconv(.c) void {
_ = input;
const self: *CountingPlayer = @ptrCast(@alignCast(device.?.*.pUserData));
const out: [*]f32 = @ptrCast(@alignCast(output.?));
const wanted = @as(usize, frame_count) * self.channels;
var i: usize = 0;
while (i < wanted) : (i += 1) {
out[i] = self.ring.pop() orelse blk: {
_ = self.underruns.fetchAdd(1, .monotonic);
break :blk 0.0;
};
}
}
};
The counter is bumped from the audio thread, which is exactly why it is atomic -- the main thread reads it at the end without a lock. Every serious audio library exposes a number like this (JACK calls them xruns), and it is the first thing you look at when something crackles. Right -- three loose ends tied. Now let us build the thing that makes the sound.
The phase accumulator is the whole trick
Strip away every waveform and every synth feature, and what is left at the bottom is one idea: a phase accumulator. You keep a single number -- the phase -- that says "how far through one cycle am I right now". Each sample you add a fixed increment; when the phase runs past the end of a cycle, you wrap it back to the start. That is it. The frequency is nothing more than how big the increment is: a bigger step per sample means you race through cycles faster, which means a higher pitch.
Last episode I kept the phase in radians (0 to 2*pi) because the sine wanted it that way. But for a general oscillator that has to be four different shapes, radians are clumsy. I am going to switch to a normalized phase: a value from 0.0 to 1.0 representing one full cycle. The increment then becomes beautifully simple -- freq / sample_rate, the fraction of a cycle you advance each sample -- and every waveform can be written as a plain function of a 0..1 ramp. That single design choice makes the code below almost trivial, which is the point: pick the representation that makes the hard part easy.
const std = @import("std");
// Normalized phase in 0..1. Advance by `increment` each sample; wrap at 1.0.
var phase: f32 = 0.0;
const increment: f32 = 440.0 / 48_000.0; // fraction of a cycle per sample
fn tick() f32 {
const p = phase;
phase += increment;
if (phase >= 1.0) phase -= 1.0;
return p; // p is a rising 0..1 ramp -- already a sawtooth, almost
}
Notice that the raw phase ramp -- a value climbing from 0 to 1 and snapping back -- is already a sawtooth if you rescale it to -1..1. Every other waveform is just a different reading of that same ramp. That is the elegant secret nobody tells you up front: you do not build four oscillators, you build one phase accumulator and read it four ways.
Four waveforms from one struct
Let us wrap that up properly. An Oscillator holds its phase, its increment and which waveform it currently is (an enum, episode 6). The next() method advances the phase once and returns one sample, switching on the waveform to shape the 0..1 ramp into the classic shapes:
const std = @import("std");
pub const Waveform = enum { sine, saw, square, triangle };
pub const Oscillator = struct {
phase: f32 = 0.0, // normalized 0..1
increment: f32, // freq / sample_rate
waveform: Waveform = .sine,
pub fn init(freq: f32, sample_rate: u32) Oscillator {
return .{ .increment = freq / @as(f32, @floatFromInt(sample_rate)) };
}
pub fn setFrequency(self: *Oscillator, freq: f32, sample_rate: u32) void {
self.increment = freq / @as(f32, @floatFromInt(sample_rate));
}
pub fn next(self: *Oscillator) f32 {
const p = self.phase;
const value: f32 = switch (self.waveform) {
.sine => @sin(p * 2.0 * std.math.pi),
.saw => 2.0 * p - 1.0, // rising ramp, -1..1
.square => if (p < 0.5) 1.0 else -1.0, // 50% duty cycle
.triangle => 4.0 * @abs(p - 0.5) - 1.0, // /\ shape, -1..1
};
self.phase += self.increment;
if (self.phase >= 1.0) self.phase -= 1.0;
return value;
}
};
Read each arm against the 0..1 ramp and it falls out cleanly. The saw is the ramp stretched to -1..1. The square is +1 for the first half of the cycle and -1 for the second. The triangle folds the ramp at the midpoint: @abs(p - 0.5) makes a V from 0.5 down to 0 and back up, and the 4.0 * ... - 1.0 rescales it to a proper -1..1 triangle. The sine is the only one that reaches for @sin, converting the normalized phase back to radians on the spot. One struct, one method, four instruments.
Filling a whole buffer is then the same shape we have used since episode 169 -- a loop that asks the oscillator for the next sample:
/// Fill a mono buffer with successive samples from the oscillator.
pub fn fill(osc: *Oscillator, out: []f32) void {
for (out) |*sample| sample.* = osc.next();
}
Drop fill into last episode's producer and you now have a synth voice feeding the ring instead of a hard-coded sine. Change osc.waveform and the timbre changes; call osc.setFrequency(...) and the pitch bends. That is genuinely a playable tone generator in about thirty lines. Having said that, if you actually run the saw or the square through a speaker, you will hear a problem -- a harsh, gritty buzz sitting on top of the note that gets worse the higher you play. That buzz is aliasing, and it is the single most important thing to understand about digital oscillators.
Aliasing: why the naive saw sounds wrong
Here is the physics, kept honest. A sampled system can only represent frequencies up to half the sample rate -- the Nyquist limit, 24000 Hz at a 48000 Hz rate. A perfect sawtooth or square is mathematically made of an infinite stack of harmonics: a saw at 1000 Hz contains energy at 2000, 3000, 4000 Hz and up, forever. The naive formulas above happily generate all of those harmonics -- including the ones above Nyquist. But a digital system cannot store a harmonic above Nyquist; instead that energy folds back (aliases) down to some unrelated lower frequency, landing on tones that are not musically related to the note you played. The ear is merciless about this: it hears the folded-back partials as a dirty, inharmonic buzz.
The sine is fine -- it has exactly one harmonic, well below Nyquist, so there is nothing to fold. The saw and square are the offenders, because their instantaneous jumps (the vertical cliff each cycle) are precisely what an infinite harmonic series looks like in the time domain. So the fix is to soften those jumps by just the right amount: remove the alias energy without dulling the tone. The cheap, popular way to do that in a real-time oscillator is PolyBLEP (Polynomial Band-Limited step). It is a small correction you subtract right around each discontinuity, computed from how far the phase is from the jump, scaled by the increment:
/// PolyBLEP correction around a step, given phase t (0..1) and step size dt.
fn polyBlep(t_in: f32, dt: f32) f32 {
var t = t_in;
if (t < dt) { // just after the wrap: smooth the leading edge
t /= dt;
return t + t - t * t - 1.0;
} else if (t > 1.0 - dt) { // just before the wrap: smooth the trailing edge
t = (t - 1.0) / dt;
return t * t + t + t + 1.0;
}
return 0.0; // away from the jump, no correction needed
}
The two branches catch the samples immediately after and immediately before the once-per-cycle jump; everywhere else the correction is zero, so it costs almost nothing. A band-limited sawtooth is then the naive ramp with this correction subtracted at the discontinuity:
pub fn nextSawBandLimited(self: *Oscillator) f32 {
const p = self.phase;
var value = 2.0 * p - 1.0; // naive saw
value -= polyBlep(p, self.increment); // shave the aliasing off the cliff
self.phase += self.increment;
if (self.phase >= 1.0) self.phase -= 1.0;
return value;
}
Do not let the polynomial intimidate you -- the takeaway is the principle, not the algebra: a raw digital edge aliases, and you tame it by band-limiting the edge. PolyBLEP is the pragmatic choice for a live oscillator because it is one cheap branch per sample; the alternatives (additive synthesis of finite harmonics, or BLIT/minBLEP tables) are more accurate but heavier. For a square wave you subtract a BLEP at both the rising and the falling edge, or -- a neat trick -- build the square as the difference of two band-limited saws a half cycle apart. Measure-first (episode 34) applies here too: if your oscillator only ever plays low notes, the naive version may be good enough, but the moment you go high, you will want the correction.
Wavetables: trading memory for speed
There is a second classic oscillator design worth knowing, because it powers an enormous number of real synths: the wavetable. Instead of computing the shape with @sin or a polynomial every sample, you precompute one cycle of the waveform into an array once, then just read out of it, stepping through the table at a rate set by the pitch. A @sin per sample is not free; an array read plus a lerp usually is cheaper, and -- more importantly -- a table can hold any shape you like, including ones with no closed-form formula (a sampled analog waveform, a drawn shape, a spectrum you designed).
pub const WavetableOsc = struct {
table: []const f32, // exactly one cycle
phase: f32 = 0.0, // 0..1
increment: f32,
pub fn next(self: *WavetableOsc) f32 {
const len_f: f32 = @floatFromInt(self.table.len);
const pos = self.phase * len_f; // where we are in the table
const i0: usize = @intFromFloat(pos);
const i1 = (i0 + 1) % self.table.len; // wrap for interpolation
const frac = pos - @floor(pos);
const a = self.table[i0];
const b = self.table[i1];
self.phase += self.increment;
if (self.phase >= 1.0) self.phase -= 1.0;
return a + (b - a) * frac; // linear interpolation between neighbours
}
};
The linear interpolation matters: the phase almost never lands exactly on a table index, so we read the two neighbouring samples and blend between them by the fractional part. Skip that and you get a stair-stepped, noisy read (nearest-neighbour) that -- surprise -- aliases again. And where does the table come from? This is where Zig grins. Because comptime (episode 9) runs ordinary Zig at compile time, we can generate the table during the build, so it costs nothing at runtime and lives in the binary as constant data:
/// Build a one-cycle sine table at COMPILE time. Zero runtime cost.
pub fn sineTable(comptime n: usize) [n]f32 {
var t: [n]f32 = undefined;
for (0..n) |i| {
const phase = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(n));
t[i] = @sin(phase * 2.0 * std.math.pi);
}
return t;
}
const sine_1024 = sineTable(1024); // computed by the compiler, not at startup
In C you would either hand-write the numbers, generate them with a separate script, or fill the table in an init function that runs at startup. Zig lets you write the obvious loop and just mark it comptime, and the compiler does the trig. That is the same superpower we used for compile-time reflection back in episode 32, pointed at a different problem. Nota bene: proper wavetable synthesis uses multiple tables per waveform (one per octave band, each with harmonics above Nyquist stripped) to stay alias-free across the whole keyboard -- but a single table is the right place to start, and already teaches the core idea.
Testing an oscillator with no speaker
The same discipline that saved us last episode saves us here: do not test the sound, test the numbers. An oscillator is a pure function of its phase -- given a frequency and a sample rate, the output is completely deterministic -- so it is one of the most testable things in the whole audio stack. No device, no ears, just assertions on the slice you filled. A sawtooth, for instance, must be a strictly rising ramp inside -1..1 until it wraps:
test "sawtooth ramps upward and stays in range before wrapping" {
var osc = Oscillator.init(100.0, 48_000); // slow: many samples per cycle
osc.waveform = .saw;
var prev = osc.next();
for (0..64) |_| {
const s = osc.next();
try std.testing.expect(s >= -1.0 and s <= 1.0);
try std.testing.expect(s > prev); // strictly rising, no wrap yet
prev = s;
}
}
And a square wave at a frequency that divides the sample rate evenly must be perfectly symmetric -- equal time at +1 and -1 -- so its sum over a whole number of cycles is zero (no DC offset):
test "square wave has no DC offset over a full cycle" {
var osc = Oscillator.init(480.0, 48_000); // exactly 100 samples per cycle
osc.waveform = .square;
var sum: f32 = 0.0;
for (0..100) |_| sum += osc.next();
try std.testing.expectApproxEqAbs(@as(f32, 0.0), sum, 1e-4);
}
These two tests catch the mistakes people actually make: an off-by-one in the ramp rescale, a wrong duty-cycle comparison, a phase that fails to wrap. A DC-offset check is especially valuable because a stray offset is inaudible on its own but wastes headroom and thumps when you start and stop the sound. This is the lesson from episode 12 made concrete for audio -- push the logic somewhere a test can reach it, and the "I have to listen to it" part shrinks to almost nothing.
Performance: the oscillator lives on the hot path
Remember the commandment from last episode: the callback must return before the device needs the next block, every time. An oscillator often runs inside that budget (or on the producer just ahead of it), so a few habits matter:
- Hoist the constants.
2.0 * std.math.piand the sample-rate conversion do not change per sample -- compute them once, outside the loop. The compiler often does this for you, but do not rely on luck in a hot loop. - Mind the per-sample
switch. Branching on the waveform every single sample is a tiny cost, but if you know the whole block is one waveform, hoist the branch out and run a tight per-shape loop. This also lets the vectorizer work. - Reach for
@Vector(episode 19) for the cheap shapes. A saw or a square is pure arithmetic, so a whole block can be built in SIMD lanes -- generate the phase ramp, then shape it -- rather than one sample at a time.
/// Vectorized naive saw: build 8 samples per iteration. (Aliases -- illustrative.)
pub fn fillSawSimd(out: []f32, start_phase: f32, increment: f32) f32 {
const lanes = 8;
var phase = start_phase;
var i: usize = 0;
while (i + lanes <= out.len) : (i += lanes) {
var ramp: @Vector(lanes, f32) = undefined;
inline for (0..lanes) |k| {
ramp[k] = phase;
phase += increment;
if (phase >= 1.0) phase -= 1.0;
}
const shaped = ramp * @as(@Vector(lanes, f32), @splat(2.0)) -
@as(@Vector(lanes, f32), @splat(1.0));
out[i..][0..lanes].* = shaped;
}
return phase; // hand the phase back so the next block continues seamlessly
}
The measure-first rule still governs (episode 34). For one tone, the scalar next() loop is plenty and clearer -- reach for SIMD only when a profiler shows the oscillator is actually hot, which it becomes fast once you are mixing dozens of voices. The important structural point is returning the phase so successive blocks join without a click, the same continuity discipline as the atomic-increment sweep above.
The same job in C, Rust and Go
The phase-accumulator idea is universal -- it is the same three lines in every language, because it is really just arithmetic. What differs is how each language wants you to package the waveform choice. In C, the classic shape is a switch on an enum, state carried in a plain struct you pass around by pointer -- functional and blunt, exactly our next() translated back to its roots:
// C: the same phase accumulator, waveform chosen by an enum switch.
float osc_next(Oscillator* o) {
float p = o->phase, v = 0.0f;
switch (o->waveform) {
case SINE: v = sinf(p * 2.0f * (float)M_PI); break;
case SAW: v = 2.0f * p - 1.0f; break;
case SQUARE: v = p < 0.5f ? 1.0f : -1.0f; break;
case TRIANGLE: v = 4.0f * fabsf(p - 0.5f) - 1.0f; break;
}
if ((o->phase += o->increment) >= 1.0f) o->phase -= 1.0f;
return v;
}
Rust typically models the waveform as an enum too, but a richer one, and matches on it -- and its iterator traits make an oscillator compose naturally into a lazy stream of samples, which reads nicely though it buys you nothing the plain loop lacks:
// Rust: an enum + match, the oscillator as an Iterator of samples.
impl Iterator for Oscillator {
type Item = f32;
fn next(&mut self) -> Option<f32> {
let p = self.phase;
let v = match self.waveform {
Waveform::Sine => (p * std::f32::consts::TAU).sin(),
Waveform::Saw => 2.0 * p - 1.0,
Waveform::Square => if p < 0.5 { 1.0 } else { -1.0 },
Waveform::Triangle => 4.0 * (p - 0.5).abs() - 1.0,
};
self.phase += self.increment;
if self.phase >= 1.0 { self.phase -= 1.0; }
Some(v)
}
}
Go would use a small struct and a method, but its comptime story is the interesting contrast: there is no compile-time table generation, so a Go wavetable is filled by an init() or a sync.Once at startup, paying that cost at runtime rather than in the build. And the garbage collector means you engineer even harder to keep the oscillator allocation-free, exactly the pull-model glitch risk we designed around last episode. Where does Zig sit? Our next() is as blunt and fast as the C, the enum switch is as clear as the Rust match, and comptime hands us the wavetable for free where both C and Go make you build it -- one language covering all three ergonomics without a runtime tax. That is the trade the whole audio arc keeps showing off.
Exercises
A pulse-width knob. Generalize the square wave into a pulse oscillator with a
dutyfield (0.0 to 1.0): output +1 whilephase < duty, else -1. Sweep the duty from 0.5 down to 0.05 over two seconds and listen to (or plot) how the timbre thins out. Add a test that a duty of 0.25 spends a quarter of each cycle high.Detune two saws. Run two sawtooth oscillators a few cents apart in pitch, sum them, and divide by two. This is the classic "supersaw" fatness. Write a test that the summed output stays inside
-1..1, and think about why two slightly detuned waves drift in and out of phase (the "beating" you hear).A wavetable from your own shape. Fill a 1024-entry table at comptime with a waveform of your choice -- try the first few harmonics of a saw added together (
sin(x) + sin(2x)/2 + sin(3x)/3 + ...), which is a naturally band-limited approximation. Drive aWavetableOscfrom it and compare, by ear or by a spectrum, against the naive saw. Which one buzzes?
What we learned
- Every oscillator is one phase accumulator -- a number climbing
0..1byfreq / sample_rateeach sample and wrapping at the cycle boundary -- and frequency is nothing but the size of that step; - All four classic waveforms are just different readings of that same ramp: the saw is the ramp itself, the square a half-cycle comparison, the triangle a fold at the midpoint, and the sine the ramp pushed through
@sin; - The naive saw and square alias because their instant jumps carry harmonics above Nyquist that fold back as an inharmonic buzz -- a cheap PolyBLEP correction shaves the alias energy off each edge;
- A wavetable oscillator reads a precomputed cycle with linear interpolation, trading a little memory for speed and arbitrary shapes -- and Zig's comptime (episode 9) bakes that table into the binary with an ordinary loop, where C and Go pay at startup;
- An oscillator is a pure, deterministic function of its phase, so you test the numbers, not the sound -- a rising-ramp check and a no-DC-offset check catch the real bugs with no speaker involved;
- On the hot path, hoist constants, avoid a per-sample branch when the block is one shape, and reach for
@Vector(episode 19) only once a profiler says the oscillator is hot -- measure first (episode 34).
So the buffer stops being a static tone and starts being a voice you can pitch, shape and choose the timbre of. But a raw oscillator still just drones -- hold a key and it never changes, never breathes. What turns a drone into an instrument is shaping it over time: how the note swells and decays, how you carve harmonics out of it, how you round off its edges. That is where we go next, and it is where a bare tone finally starts to sound alive. Plenty still to build, and it keeps getting more fun.
De groeten, en tot de volgende keer! ;-)