Learn Zig Series (#171) - Synthesis: Oscillators

Words
4751
Reading
22 min
Listen
Play
20h

Learn Zig Series (#171) - Synthesis: Oscillators

zig.png

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):

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.pi and 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

  1. A pulse-width knob. Generalize the square wave into a pulse oscillator with a duty field (0.0 to 1.0): output +1 while phase < 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.

  2. 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).

  3. 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 a WavetableOsc from 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..1 by freq / sample_rate each 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! ;-)

scipio@scipio

Learn Zig Series (#171) - Synthesis: Oscillators | Ecency