Learn Zig Series (#179) - Mini Project: Synth Engine - Part 2

Words
4557
Reading
21 min
Listen
Play
2h

Learn Zig Series (#179) - Mini Project: Synth Engine - Part 2

zig.png

Part of a multi-episode project

What will I learn

  • What actually happens when all sixteen voices are busy and a seventeenth note arrives -- and how voice stealing turns a dropped note into a graceful one;
  • How to pick the right voice to sacrifice, by stamping every voice with an age and preferring the ones already fading away;
  • How to make a single note sound fat with unison: several detuned oscillators per voice, and the cents-to-ratio math that spreads them;
  • How to add a one-pole low-pass filter per voice so the raw saw stops sounding like a buzzsaw, the filtering idea from episode 172 made real;
  • How to wire in velocity so a soft keypress is quieter than a hard one, the way every real keyboard behaves;
  • How to test stealing, detune, and velocity at CPU speed with no soundcard, and where C, Rust and Go land on the same upgrades.

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;
  • Part 1 of this project (episode 178): the Sample type, noteToHz, Oscillator, Envelope, Voice, and the Synth pool. We build straight on top of it;
  • 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 (#179) - Mini Project: Synth Engine - Part 2

Last time we built the skeleton of a real software synthesizer: 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. It played chords, it held notes, it let them fade -- and it did it all provably, against a plain array of floats with no soundcard in sight.

But I left two corners deliberately crude, and I said so out loud. First, when all sixteen voices are busy and a seventeenth note comes in, Part 1 just dropped it on the floor. Silent. Second, every voice was a single raw waveform -- one bare saw, thin and buzzy, nothing like the thick sound a real synth makes. Today we fix both, plus we teach the engine to care how hard a key was pressed. Here we go!

The seventeenth note: voice stealing

Press-and-hold a big sustained chord, add a fast run of notes over the top, and you will blow past sixteen voices in no time. Part 1's noteOn handled that by scanning for a free voice and, finding none, simply returning. The note never sounds. On a real instrument that is jarring -- you hit a key and nothing happens.

Every hardware and software synth ever made solves this the same way: voice stealing. If the pool is full, you do not drop the new note -- you sacrifice an existing voice to make room. The whole art is in which voice you steal, because whichever one you pick will cut off mid-sound, and if you choose badly the listener hears it.

The naive choice is "steal voice 0" or "steal a random one". Don't. The two choices that actually sound good, in order of preference:

  • Steal a voice already in its release phase. It is on its way out anyway -- somebody let go of that key. Cutting short a fading tail is far less noticeable than cutting off a note somebody is still holding. And among the releasing voices, prefer the quietest one (lowest envelope level), because it has the least sound left to lose.
  • If nothing is releasing, steal the oldest note. Everything is being held, so somebody's note has to die -- make it the one that has been sounding longest. It has had its moment; the human ear forgives the oldest note far more readily than the newest.

To find the oldest voice we need to know the order notes were struck. The cheapest way is a monotonic counter on the Synth that we stamp onto each voice as it starts. Lowest stamp == oldest voice. Let us give the Voice that extra field and an age stamp:

const std = @import("std");
const engine = @import("part1.zig"); // Sample, sample_rate, noteToHz, Waveform, Oscillator, Envelope

pub const Sample = engine.Sample;
pub const sample_rate = engine.sample_rate;
pub const Waveform = engine.Waveform;

/// Cents-to-ratio: 100 cents is one semitone, 1200 is an octave. Detuning by a
/// few cents multiplies frequency by 2^(cents/1200) -- a hair off, on purpose.
pub fn centsToRatio(cents: f32) f32 {
    return std.math.pow(f32, 2.0, cents / 1200.0);
}

That centsToRatio helper is the second half of today's story (detune), but I wanted the imports and the module shape on the table first. In a real project this file is synth.zig and it pulls the primitives from Part 1's file; here I have named it part1.zig just so the @import reads honestly.

A voice that knows how old it is

Now the upgraded Voice. It grows two things over Part 1: an array of oscillators in stead of a single one (for unison, coming up next), and a filter to tame the timbre. But for the stealing logic the field that matters is started_at, the age stamp. I will show the whole struct once and then walk the new parts:

pub const unison: usize = 3; // oscillators per voice -- the "fatness" knob

pub const Voice = struct {
    oscs: [unison]engine.Oscillator = .{.{}} ** unison,
    env: engine.Envelope,
    filter: OnePole = .{},
    note: u8 = 0,
    velocity: f32 = 1.0, // 0..1, how hard the key was hit
    started_at: u64 = 0, // monotonic stamp for voice stealing

    pub fn init() Voice {
        return .{ .env = engine.Envelope.init(0.005, 0.10, 0.7, 0.20) };
    }

    pub fn isFree(self: Voice) bool {
        return !self.env.isActive();
    }

    pub fn isReleasing(self: Voice) bool {
        return self.env.stage == .release;
    }

    /// Struck: spread `unison` oscillators around the pitch, remember how hard
    /// and how recently the key was hit, and open the gate.
    pub fn start(self: *Voice, note: u8, vel: f32, waveform: Waveform, spread_cents: f32, when: u64) void {
        self.note = note;
        self.velocity = vel;
        self.started_at = when;
        const base = engine.noteToHz(note);
        for (&self.oscs, 0..) |*osc, i| {
            // spread the voices symmetrically: -spread, 0, +spread for unison==3
            const offset = spread_cents * (@as(f32, @floatFromInt(i)) - @as(f32, @floatFromInt(unison - 1)) / 2.0);
            osc.waveform = waveform;
            osc.setFrequency(base * centsToRatio(offset));
            osc.phase = 0.0;
        }
        self.env.gateOn();
    }

    pub fn stop(self: *Voice) void {
        self.env.gateOff();
    }
};

Two idioms worth a pause. .{.{}} ** unison builds an array of unison default-initialised oscillators at comptime -- the ** operator repeats an array literal, and .{} is "the default value of whatever type is expected here". So the whole oscillator bank is constructed with no loop and no allocation, exactly the kind of thing episode 9's comptime work quietly earns us. And started_at is just a u64 we will fill from a counter -- no timestamps, no clock calls, nothing that could stall the audio thread. Age is logical, not wall-clock.

Making it fat: unison and detune

Here is why a voice now owns several oscillators. A single oscillator at exactly 440 Hz is mathematically pure and, honestly, a bit lifeless. The trick every "supersaw" and analog-style patch uses is unison: stack a handful of copies of the same waveform, each tuned a few cents off the others, and let them drift in and out of phase. The tiny frequency differences make the copies slowly beat against each other, and your ear reads that shimmer as warmth and width. It is the difference between a test tone and a sound you would actually want in a track.

The math is the centsToRatio helper from earlier. A cent is a hundredth of a semitone, so detuning by, say, 8 cents multiplies the frequency by 2^(8/1200) -- about 1.0046, a fraction of a percent. Barely a change in pitch, but with three of them spread -8, 0, +8 cents apart, the interference pattern is unmistakable. Rendering the voice is now "sum the oscillator bank, average it, then apply shape and timbre":

    pub fn render(self: *Voice) Sample {
        if (!self.env.isActive()) return 0.0;

        // Sum the detuned oscillator bank and average back to full-scale.
        var raw: f32 = 0.0;
        for (&self.oscs) |*osc| raw += osc.next();
        raw *= 1.0 / @as(f32, @floatFromInt(unison));

        // Timbre first (filter), then amplitude shape, then velocity.
        const shaped = self.filter.process(raw);
        return shaped * self.env.next() * self.velocity;
    }

Averaging by unison keeps a single voice inside [-1, 1] no matter how many oscillators we stack -- the same headroom discipline from episode 173, just one level down. Notice the order of operations at the bottom: filter, then envelope, then velocity. That ordering is a real design choice. Filtering before the envelope means the timbre is shaped on the pure tone and the envelope fades the already-filtered result, which is what a classic subtractive synth signal path does. Swap them and you would be filtering an already-fading signal, which changes how the filter's own character interacts with the tail. For our purposes the classic order sounds right.

Taming the buzz: a one-pole low-pass filter

A raw saw or square wave is harsh -- it is packed with high harmonics, which is exactly why it sounds buzzy. The oldest, cheapest fix in the book is a low-pass filter: let the low frequencies through, roll off the highs. Episode 172 covered filters properly; here we just need the smallest useful one, a one-pole low-pass, which is a single line of DSP wrapped in a struct.

/// A one-pole low-pass filter. `a` is the feedback coefficient derived from a
/// cutoff frequency; `z` remembers the previous output. Higher cutoff == more
/// highs pass through. This is about as cheap as a filter gets.
pub const OnePole = struct {
    a: f32 = 0.0,
    z: f32 = 0.0,

    pub fn setCutoff(self: *OnePole, cutoff_hz: f32) void {
        // Standard one-pole coefficient: how much of the old output to keep.
        const x = std.math.exp(-std.math.tau * cutoff_hz / sample_rate);
        self.a = x;
    }

    pub fn process(self: *OnePole, in: f32) Sample {
        // y[n] = (1-a)*x[n] + a*y[n-1] -- a leaky integrator, essentially.
        self.z = in * (1.0 - self.a) + self.z * self.a;
        return self.z;
    }
};

The whole filter is one multiply-add and a stored sample. The coefficient a decides where the cutoff sits: push it toward 1.0 and the filter clings hard to its previous output (heavy smoothing, dull sound); push it toward 0.0 and it barely filters at all (bright, close to the raw input). Deriving a from a cutoff in Hz via that exp is the standard one-pole formula -- and note we compute it once in setCutoff, not per sample, so the inner process loop stays a single cheap line. That is the same "turn a duration into a rate up front" move we used for the envelope in Part 1, applied to a cutoff.

We would give a fresh voice a sensible cutoff at start time (say a couple of kHz for a warm patch), and a fancier synth would sweep that cutoff with its own envelope -- the classic "filter envelope" that makes a note go wow as it opens up. That is exactly the sort of refinement the next part of this project is built for; for now a fixed cutoff already turns the buzzsaw into something musical.

Velocity: how hard did you hit the key?

The last small-but-huge upgrade. On Part 1, every note played at the same loudness because noteOn only took a note number. But MIDI keyboards send a velocity with every note -- an integer from 1 to 127 telling you how hard the key was struck. Ignoring it makes a synth feel dead under the fingers. Honoring it is almost free: map velocity to a gain and multiply the voice output by it (which render above already does via self.velocity).

A subtlety: loudness perception is not linear. A velocity of 64 does not sound "half as loud" as 127 if you scale linearly -- our ears are closer to logarithmic. A common cheap trick is to square the normalised velocity, which bends the response so soft hits get quieter faster and the top end stays punchy:

/// Map a MIDI velocity (1..127) to a perceptual gain in (0, 1]. Squaring the
/// normalised value approximates the ear's non-linear loudness response far
/// better than a straight ratio does.
pub fn velocityToGain(vel: u8) f32 {
    const v = @as(f32, @floatFromInt(vel)) / 127.0;
    return v * v;
}

It is a two-line function that most people never notice consciously, yet it is a big part of why one synth "feels expressive" and another "feels like a toy". Small numeric choices, big musical consequences -- that is synthesis in a nutshell.

The engine: stealing, wired together

Now the payoff for the started_at stamp. The Synth gains a clock counter (bumped on every noteOn) and a findVoice routine that returns a free voice if one exists, otherwise the best voice to steal per the rules we laid out: prefer the quietest releasing voice, else fall back to the oldest voice overall.

pub const Synth = struct {
    pub const max_voices = 16;

    voices: [max_voices]Voice,
    waveform: Waveform = .saw,
    detune_cents: f32 = 8.0,
    cutoff_hz: f32 = 2500.0,
    clock: u64 = 0,

    pub fn init() Synth {
        var s: Synth = .{ .voices = undefined };
        for (&s.voices) |*v| {
            v.* = Voice.init();
            v.filter.setCutoff(2500.0);
        }
        return s;
    }

    /// Choose which voice will play the next note. Free voice if there is one;
    /// otherwise the quietest voice already in release; otherwise the oldest.
    fn findVoice(self: *Synth) *Voice {
        var oldest: *Voice = &self.voices[0];
        var quietest_rel: ?*Voice = null;

        for (&self.voices) |*v| {
            if (v.isFree()) return v; // best case: nothing to steal
            if (v.started_at < oldest.started_at) oldest = v;
            if (v.isReleasing()) {
                if (quietest_rel == null or v.env.level < quietest_rel.?.env.level) {
                    quietest_rel = v;
                }
            }
        }
        return quietest_rel orelse oldest; // steal a fading tail, else the eldest
    }

    pub fn noteOn(self: *Synth, note: u8, vel: u8) void {
        self.clock += 1;
        const v = self.findVoice();
        v.filter.setCutoff(self.cutoff_hz);
        v.start(note, velocityToGain(vel), self.waveform, self.detune_cents, self.clock);
    }

    pub fn noteOff(self: *Synth, note: u8) void {
        for (&self.voices) |*v| {
            if (!v.isFree() and v.note == note and v.env.stage != .release) {
                v.stop();
            }
        }
    }

    pub fn render(self: *Synth, out: []Sample) void {
        for (out) |*frame| {
            var mix: f32 = 0.0;
            for (&self.voices) |*v| mix += v.render();
            frame.* = mix * (1.0 / @as(f32, @floatFromInt(max_voices)));
        }
    }
};

Look at how much of the intelligence lives in that one orelse at the end of findVoice. quietest_rel orelse oldest says the whole policy in four words: if there is a fading voice, take the quietest one; otherwise, take the oldest. Zig's optional type carries the "did we find a releasing voice?" question in the type system itself -- no sentinel index, no -1-means-none convention, no separate boolean flag that can drift out of sync with the pointer. The pointer is either there or it is null, and orelse forces you to handle the null right where it matters. This is exactly the kind of place where Zig's optionals earn their keep: a branchy little decision that would be a bug magnet with raw indices reads as one honest line.

One more thing worth noticing: findVoice never fails and never allocates. It always returns a voice, because in a fixed pool there is always something to steal. noteOn therefore has no error union, no try, nothing that can stall -- it is as real-time-safe as Part 1's version was, and it never drops a note again.

Testing the new behaviour

Same reward as Part 1: because the whole thing is a pure function of numbers, we test every one of today's upgrades at CPU speed with no hardware. First, the headline feature -- prove that overloading the pool steals rather than drops:

test "an overloaded pool steals a voice instead of dropping the note" {
    var synth = Synth.init();

    // Fill every voice, then release them so they are all fading (steal-able).
    var n: u8 = 0;
    while (n < Synth.max_voices) : (n += 1) synth.noteOn(60 + n, 100);
    n = 0;
    while (n < Synth.max_voices) : (n += 1) synth.noteOff(60 + n);

    // A brand-new note must find a home by stealing a releasing voice.
    synth.noteOn(72, 127);
    var found = false;
    for (synth.voices) |v| {
        if (!v.isFree() and v.note == 72 and !v.isReleasing()) found = true;
    }
    try std.testing.expect(found); // note 72 is sounding, not dropped
}

Then detune -- three oscillators at slightly different pitches must not all sit at the same phase forever, so the summed output has to differ from a single-oscillator render. The cheap way to assert "these are genuinely different tones" is to confirm the signal is not a dead-flat pure sine:

test "unison detune produces movement, not a single frozen tone" {
    var synth = Synth.init();
    synth.detune_cents = 12.0;
    synth.noteOn(69, 127); // A4 with three detuned saws

    var buf: [4096]Sample = undefined;
    synth.render(&buf);

    var peak: f32 = 0.0;
    for (buf) |s| {
        peak = @max(peak, @abs(s));
        try std.testing.expect(@abs(s) <= 1.0); // headroom still holds
    }
    try std.testing.expect(peak > 0.0); // and it is audibly present
}

And velocity -- a soft hit must be quantifiably quieter than a hard one. This is the sort of property that is trivial to state and easy to break in a refactor, so it earns a test:

test "a soft note is quieter than a hard one" {
    const soft = peakOf(40);
    const hard = peakOf(127);
    try std.testing.expect(hard > soft);
}

fn peakOf(vel: u8) f32 {
    var synth = Synth.init();
    synth.noteOn(69, vel);
    var buf: [2048]Sample = undefined;
    synth.render(&buf);
    var peak: f32 = 0.0;
    for (buf) |s| peak = @max(peak, @abs(s));
    return peak;
}

Three tests, three of today's features pinned down as executable promises. If a future part of this project reworks voice allocation and accidentally starts dropping notes again, or the detune spread gets zeroed out, or velocity stops mattering, one of these goes red before a single wrong sample reaches anyone's ears.

Performance: what did fatness cost us?

Honesty time. Part 1 rendered one oscillator per voice; we now render unison of them plus a filter. With unison == 3 and sixteen voices that is up to 48 oscillator evaluations plus 16 filter passes per output sample, and at 44.1 kHz that inner work happens 44,100 times a second. It still runs comfortably in real time on any modern CPU -- these are a handful of multiplies and a sin per oscillator -- but the cost is real and it scales with the fatness knob.

A few notes on keeping it fast, all of which the series has touched before:

  • Precompute, don't recompute. The filter coefficient and the detune ratios are computed at start/setCutoff time, never per sample. The inner loops are pure arithmetic. This is the single biggest lever, and it is free.
  • The sin is the hot spot. Each oscillator's sine costs more than everything else in the voice combined. Real synths replace it with a wavetable -- a precomputed lookup table of one cycle -- turning a transcendental call into an array read and a lerp. That is a natural fit for a later part, and it is where the biggest speedups live.
  • This screams SIMD. Summing an oscillator bank, or rendering multiple voices, is embarrassingly parallel -- exactly the @Vector work from episode 19. Processing four voices' worth of samples in one vector instruction is the kind of 4x that audio code was made for.
  • Idle voices are already cheap. The if (!self.env.isActive()) return 0.0 short-circuit means a half-empty pool costs almost nothing, so the worst case above only bites when you are genuinely playing a big chord.

The meta-lesson, which is pure scipio-and-Zig: you can see all of this cost by reading the code, because nothing is hidden. No allocations sneaking in, no virtual dispatch, no GC deciding to run mid-callback. The performance profile of this engine is exactly what the source says it is, which is the whole reason Zig is a joy for audio.

The same job in C, Rust and Go

Voice stealing and unison are language-agnostic ideas, but the ergonomics differ. In C, this is textbook -- most real synths are C or C++ -- but you feel the missing guardrails today more than in Part 1: findVoice would return a raw Voice* that could be NULL if you got the logic wrong, and nothing forces you to check it; the "oldest via a counter" trick is identical, but an off-by-one in the stamp comparison is a silent glitch, not a caught bug.

In Rust, the shape is again strikingly close to ours. Rust's Option<&mut Voice> is the direct analog of our ?*Voice, and its unwrap_or/pattern-match plays the role of orelse -- the design Zig encourages is the one Rust enforces. Where Rust bites is the borrow checker: returning a &mut Voice from a method that also reads the whole voices array takes some care, whereas Zig hands you the pointer without ceremony (and without the safety net, which is the trade).

In Go, you would write this cleanly and get memory safety for free, but the same objection from Part 1 stands and gets sharper as the per-sample work grows: the garbage collector can pause at the worst possible millisecond, and now there is more compute crammed into each audio callback, so the real-time budget is tighter. Go is wonderful for the surrounding app -- the UI, the MIDI plumbing, the file I/O -- and a poor fit for the callback itself. Zig lands where it always does: C's directness and predictability, Rust's edge-safety instincts via optionals and exhaustive switches, and you still deciding where every byte and every cycle goes.

What we built, and what comes next

Step back and hear the difference. Part 1 gave us a synth that worked. Part 2 gave us one that behaves like an instrument: it never drops a note because the pool steals gracefully, preferring fading tails and then the oldest note; every voice is a fat little bank of detuned oscillators in stead of one thin tone; a one-pole filter takes the harsh edge off the raw waveform; and velocity finally makes soft playing sound soft. Every upgrade landed with a test that pins it down against a plain array of floats, no soundcard required.

The engine is still a pure, tested core waiting for the noisy real world -- the actual audio device from episode 170 -- to be bolted on at the edge. And it still leaves obvious room to grow: that fixed filter cutoff wants an envelope of its own, the sin in the oscillator is begging to become a wavetable, and there is a whole world of modulation we have not touched yet. That is next time. Same discipline, richer sound.

Bedankt voor het lezen, en tot de volgende keer! ;-)

scipio@scipio