Learn Zig Series (#180) - Mini Project: Synth Engine - Part 3

Words
4414
Reading
20 min
Listen
Play
1h

Learn Zig Series (#180) - Mini Project: Synth Engine - Part 3

zig.png

What will I learn

  • Why the per-sample @sin was 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 Sample type, noteToHz, Oscillator, Envelope, the OnePole filter, centsToRatio, the Voice, and the voice-stealing Synth pool. 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):

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 @sin per 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 @Vector work from episode 19 fits the master mix loop like a glove.
  • Idle voices stay free. The if (!self.amp_env.isActive()) return 0.0 short-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! ;-)

scipio@scipio

Learn Zig Series (#180) - Mini Project: Synth Engine - Part 3 | Ecency