Learn Zig Series (#178) - Mini Project: Synth Engine - Part 1

avatar

Learn Zig Series (#178) - Mini Project: Synth Engine - Part 1

zig.png

Part of a multi-episode project

What will I learn?

  • Why a polyphonic synthesizer is, at heart, a voice-allocation problem, and how that one insight decides the whole architecture before you write a single oscillator;
  • How to turn a MIDI note number into a frequency with twelve-tone equal temperament -- the tiny formula every synth on earth is built on;
  • How to fold the oscillator (episode 171) and the ADSR envelope (episode 172) into a single self-contained Voice that knows how to start, stop, and render itself;
  • How to build a Synth engine around a fixed pool of voices, so noteOn and noteOff never touch the allocator and the audio thread can stay real-time safe;
  • How to render a whole buffer by summing active voices with sensible headroom, the mixing lesson from episode 173 applied for real;
  • How to test the entire engine -- pitch, gating, polyphony -- at CPU speed with no soundcard, and where C, Rust and Go land on the same design.

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 audio foundations from episodes 169 to 174: PCM samples and buffers, oscillators, the ADSR envelope, and mixing;
  • Comfort with structs and enums (episode 6), fixed arrays and slices (episode 5), and testing (episode 12);
  • The ambition to learn Zig programming.

Difficulty

  • Advanced

Curriculum (of the Learn Zig Series):

Learn Zig Series (#178) - Mini Project: Synth Engine - Part 1

Last time we closed the Pixel Art Editor and I promised we would swap pixels for sound. Here we are. Over the next few episodes we are going to build a small but genuine software synthesizer -- the kind of thing that takes a stream of "play a middle C" messages and turns them into actual audio samples you could feed to a soundcard. Not a toy that beeps once, but an engine that plays chords, holds notes, lets them fade, and does it all without ever stalling the audio thread.

We did not arrive here empty-handed. Around episodes 169 to 174 we built every ingredient this project needs: PCM samples and buffers (169), audio output over C interop (170), oscillators (171), the ADSR envelope (172), mixing (173), and MIDI parsing (174). Those were components. A synth is what happens when you assemble them into one coherent machine with a clear job. Today, Part 1, is that assembly -- the voice, the note-to-pitch math, and the engine skeleton that ties them together. Here we go!

Why a synth is really a voice-allocation problem

Before any oscillator code, the single most important design decision: what plays a note? The naive answer is "the synth plays the note". But a synth has to play several notes at once -- press a three-finger chord and three pitches must sound simultaneously, each at its own point in its own fade. So the real unit of a synthesizer is not the synth, it is the voice: one self-contained sound-producing object responsible for exactly one note from the moment it is struck to the moment it falls silent.

That reframing decides the whole architecture. The engine becomes a pool of voices -- say sixteen of them -- and playing a note is really allocating a free voice, configuring it, and letting it run. Releasing a note is telling that voice to begin its fade. Rendering a buffer is asking every active voice for its next sample and summing the results. Polyphony, the headline feature of any synth, is nothing more exotic than "we have more than one voice". Once you see a synth as a voice-allocation problem, everything else is detail.

There is a second reason this framing matters, and it is pure Zig: a fixed pool of voices means zero allocation on the hot path. The audio callback runs hundreds of times a second on a real-time thread that must never block. A call to malloc in that thread can stall for a millisecond and produce an audible click -- the cardinal sin of audio programming. If the voice pool is a fixed array carved out once at startup, noteOn and render never touch the allocator at all. Zig makes that guarantee easy to see: no hidden allocations, no surprise try on the audio path.

From MIDI note to frequency

A note arrives as a number. MIDI, and every keyboard that speaks it, identifies a pitch by an integer from 0 to 127: note 69 is the A above middle C, the famous 440 Hz concert pitch, and middle C itself is 60. To turn that integer into a frequency our oscillator can use, we lean on twelve-tone equal temperament: each semitone up multiplies the frequency by the twelfth root of two, so twelve semitones (one octave) double it exactly.

const std = @import("std");

/// The engine's sample type: one mono float per frame, nominally in [-1, 1].
/// Floats keep the arithmetic simple and give us huge headroom for mixing
/// before we ever quantise back to the 16-bit integers a soundcard wants.
pub const Sample = f32;

/// Samples per second. 44_100 is CD quality and the number the whole engine
/// is tuned around -- see episode 169 where we first met it.
pub const sample_rate: f32 = 44_100.0;

/// MIDI note (69 == A4 == 440 Hz) to frequency in Hz. Every semitone is a
/// factor of 2^(1/12); twelve of them make a clean doubling (one octave).
pub fn noteToHz(note: u8) f32 {
    const semitones_from_a4 = @as(f32, @floatFromInt(note)) - 69.0;
    return 440.0 * std.math.pow(f32, 2.0, semitones_from_a4 / 12.0);
}

That one function is the bridge between the symbolic world of "the musician pressed a key" and the numeric world of "advance this oscillator by this much each sample". It is worth internalising, because it shows up unchanged in literally every synthesizer ever written. Note 81 comes out at 880 Hz (an octave above A4), note 57 at 220 Hz (an octave below) -- exact doublings and halvings, which is precisely the property equal temperament is designed to give us.

The oscillator, revisited as a component

Episode 171 built oscillators as a standalone topic. Here we want the same idea packaged so a voice can own one. The core trick is a phase accumulator: a value in [0, 1) that advances by a fixed increment every sample and wraps around. Storing phase rather than an absolute sample index is what lets us change frequency mid-note without a click -- the waveform is always read from wherever the phase currently sits.

pub const Waveform = enum { sine, saw, square, triangle };

/// A bare oscillator: a normalised phase that advances by `phase_inc` each
/// sample and wraps at 1.0. Set the pitch with setFrequency, pull one sample
/// with next. No allocation, no state beyond three floats.
pub const Oscillator = struct {
    waveform: Waveform = .sine,
    phase: f32 = 0.0,
    phase_inc: f32 = 0.0,

    /// How far to advance the phase per sample for a given pitch. A 440 Hz
    /// tone at 44.1 kHz advances 440/44100 of a cycle each sample.
    pub fn setFrequency(self: *Oscillator, hz: f32) void {
        self.phase_inc = hz / sample_rate;
    }

    pub fn next(self: *Oscillator) Sample {
        const p = self.phase;
        self.phase += self.phase_inc;
        if (self.phase >= 1.0) self.phase -= 1.0;
        return switch (self.waveform) {
            .sine => std.math.sin(p * std.math.tau),
            .saw => 2.0 * p - 1.0,
            .square => if (p < 0.5) @as(Sample, 1.0) else -1.0,
            .triangle => 4.0 * @abs(p - 0.5) - 1.0,
        };
    }
};

The switch over Waveform is the kind of code Zig makes pleasant: it is exhaustive by force, so the day you add a .noise variant the compiler marches you straight to this switch and refuses to build until you handle it. That is a small thing on a four-arm enum, but it is the same discipline that scales to a synth with thirty waveforms and keeps a whole codebase honest. Notice too that the oscillator carries no notion of loudness -- it swings full-scale between -1 and 1 always. Loudness is somebody else's job, and that somebody is the envelope.

The envelope, as a small state machine

The oscillator gives us a pitch that drones forever at full volume. Real notes do not do that -- they swell in, settle, hold, and fade out. That shape is the ADSR envelope from episode 172: Attack, Decay, Sustain, Release, four phases that scale the oscillator's output between 0 and 1 over time. The cleanest way to model it is as a tiny state machine that advances one sample at a time.

/// A classic ADSR amplitude envelope. Each stage nudges `level` toward its
/// target by a per-sample rate derived from a duration in seconds. When the
/// release finishes we drop to `.idle`, which is how the engine knows the
/// voice is free again.
pub const Envelope = struct {
    pub const Stage = enum { idle, attack, decay, sustain, release };

    attack_rate: f32,
    decay_rate: f32,
    sustain_level: f32,
    release_rate: f32,

    stage: Stage = .idle,
    level: f32 = 0.0,

    pub fn init(attack_s: f32, decay_s: f32, sustain: f32, release_s: f32) Envelope {
        return .{
            .attack_rate = 1.0 / (attack_s * sample_rate),
            .decay_rate = (1.0 - sustain) / (decay_s * sample_rate),
            .sustain_level = sustain,
            .release_rate = sustain / (release_s * sample_rate),
        };
    }

    pub fn gateOn(self: *Envelope) void {
        self.level = 0.0;
        self.stage = .attack;
    }

    pub fn gateOff(self: *Envelope) void {
        if (self.stage != .idle) self.stage = .release;
    }

    pub fn isActive(self: Envelope) bool {
        return self.stage != .idle;
    }

    pub fn next(self: *Envelope) f32 {
        switch (self.stage) {
            .idle, .sustain => {},
            .attack => {
                self.level += self.attack_rate;
                if (self.level >= 1.0) {
                    self.level = 1.0;
                    self.stage = .decay;
                }
            },
            .decay => {
                self.level -= self.decay_rate;
                if (self.level <= self.sustain_level) {
                    self.level = self.sustain_level;
                    self.stage = .sustain;
                }
            },
            .release => {
                self.level -= self.release_rate;
                if (self.level <= 0.0) {
                    self.level = 0.0;
                    self.stage = .idle;
                }
            },
        }
        return self.level;
    }
};

Turning durations into per-sample rates in init is the move that keeps next cheap: no division, no time lookups in the inner loop, just an add or a subtract and a comparison. The .idle stage doubling as "this voice is free" is the quiet keystone of the whole engine -- the envelope, not the synth, is the authority on whether a note is still sounding. When release drains level to zero and flips to .idle, the voice has genuinely gone silent, and the pool can hand it out again. One flag, and the allocation problem from the top of the episode solves itself.

Folding it into a Voice

Now the payoff. A voice is just an oscillator and an envelope glued together with the note they are currently playing. It exposes a tiny surface: start a note, stop it, render one sample. Everything above becomes an implementation detail the engine never has to think about.

/// One note's worth of sound: an oscillator for the pitch, an envelope for the
/// shape, and the note number so we know which voice to release later.
pub const Voice = struct {
    osc: Oscillator = .{},
    env: Envelope,
    note: u8 = 0,

    /// A reasonable default patch: a quick attack, short decay, a held body,
    /// and a gentle tail. Tuning these is what makes a synth sound like itself.
    pub fn init() Voice {
        return .{ .env = Envelope.init(0.005, 0.10, 0.7, 0.20) };
    }

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

    pub fn start(self: *Voice, note: u8, waveform: Waveform) void {
        self.note = note;
        self.osc.waveform = waveform;
        self.osc.setFrequency(noteToHz(note));
        self.osc.phase = 0.0;
        self.env.gateOn();
    }

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

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

Look at render: it is the entire theory of subtractive synthesis in one line -- oscillator times envelope. The pitch source multiplied by the amplitude shape. A silent voice short-circuits to 0.0 so an idle pool costs almost nothing to render. And resetting osc.phase to zero on start means every note begins at the same point in its waveform, which keeps attacks crisp and predictable in stead of starting mid-cycle at some random amplitude.

The engine: a pool of voices

The Synth itself is now almost boring, which is exactly what we want -- the cleverness lives in the voice, and the engine just manages the forementioned pool. It holds a fixed array of voices, finds a free one on noteOn, releases the matching one on noteOff, and mixes them all on render.

/// The synthesizer engine: a fixed pool of voices and the current waveform.
/// Fixed-size on purpose -- noteOn and render never allocate, so they are safe
/// to call from a real-time audio thread.
pub const Synth = struct {
    pub const max_voices = 16;

    voices: [max_voices]Voice,
    waveform: Waveform = .saw,

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

    /// Play a note: grab the first free voice and strike it. If every voice is
    /// busy we simply drop the note for now -- smarter voice stealing comes
    /// later in the project.
    pub fn noteOn(self: *Synth, note: u8) void {
        for (&self.voices) |*v| {
            if (v.isFree()) {
                v.start(note, self.waveform);
                return;
            }
        }
    }

    /// Release every sounding voice that matches this note and has not already
    /// begun its release. Chords held on the same pitch all let go together.
    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();
            }
        }
    }
};

The voices: undefined followed by an init loop is an idiom worth calling out. Zig will not let you silently read uninitialised memory, but it will let you declare an array as undefined and promise to fill it before use -- which is exactly what the loop does. It is the honest, allocation-free way to build a fixed pool: the memory is part of the Synth struct itself, so wherever you put the synth (stack, a global, inside a bigger app struct) is where the voices live. No heap, no cleanup, no deinit.

Rendering a buffer

Playing notes is meaningless until something asks for samples. That is render: for each frame in the output buffer, sum every voice and scale for headroom. This is the mixing lesson from episode 173 in miniature -- sixteen voices at full tilt would swing to +/-16 and clip horribly, so we divide the sum back down into the safe [-1, 1] range.

    /// Fill `out` with one mono sample per frame by summing all active voices.
    /// Dividing by max_voices guarantees we can never clip, at the cost of some
    /// loudness when few voices play -- a fair trade for Part 1 (episode 173).
    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)));
        }
    }
};

Dividing by the voice count is the crude-but-honest headroom strategy. It never clips, which is the one thing you must not do, but it does make a solo note quieter than it needs to be because it is reserving room for fifteen voices that are not playing. Real synths do something cleverer -- a soft limiter, or scaling by the count of active voices -- and that is exactly the sort of refinement a later part of this project is made for. For now, correct and click-free beats loud.

Testing an engine with no soundcard

Here is the reward for keeping everything a pure function of numbers: we can test the whole synth without opening an audio device at all. Rendering into a plain slice and inspecting the floats tells us everything -- pitch, gating, polyphony -- at CPU speed and with no hardware in the loop.

test "noteToHz gives concert pitch and clean octaves" {
    try std.testing.expectApproxEqAbs(@as(f32, 440.0), noteToHz(69), 0.001);
    try std.testing.expectApproxEqAbs(@as(f32, 880.0), noteToHz(81), 0.01);
    try std.testing.expectApproxEqAbs(@as(f32, 220.0), noteToHz(57), 0.01);
}

test "a struck voice makes sound, a released one eventually falls silent" {
    var synth = Synth.init();
    synth.noteOn(69);

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

    var peak: f32 = 0.0;
    for (buf) |s| peak = @max(peak, @abs(s));
    try std.testing.expect(peak > 0.0); // the note is audibly present

    synth.noteOff(69);
    var tail: [44_100]Sample = undefined; // one second, longer than the release
    synth.render(&tail);
    try std.testing.expect(synth.voices[0].isFree()); // faded and freed
}

The second test reads like a description of a keypress: strike a note, confirm sound comes out, let go, confirm it fades and the voice returns to the pool. That is the kind of test that catches the nasty bugs -- a voice that never frees itself (a leak that eats your polyphony one note at a time) or an envelope that never actually reaches zero. And polyphony gets a test of its own, because "plays a chord" is the entire reason the pool exists:

test "the engine holds several notes at once" {
    var synth = Synth.init();
    synth.noteOn(60); // C
    synth.noteOn(64); // E
    synth.noteOn(67); // G -- a C major triad

    var active: usize = 0;
    for (synth.voices) |v| {
        if (!v.isFree()) active += 1;
    }
    try std.testing.expectEqual(@as(usize, 3), active);

    var buf: [256]Sample = undefined;
    synth.render(&buf); // three voices mixing must not blow past full scale
    for (buf) |s| try std.testing.expect(@abs(s) <= 1.0);
}

That last assertion -- every sample stays inside [-1, 1] -- is the headroom guarantee turned into an executable promise. A part from proving the chord sounds, it pins down the one property a mixing stage absolutely must never violate. If a future refactor of render ever reintroduces clipping, this test goes red before a single crackle reaches anyone's ears.

Where Zig pays off, and the same job elsewhere

This engine leans on the exact strengths Zig has been quietly selling us all series. The fixed voice array means the audio path is allocation-free by construction -- there is no try, no allocator parameter, nothing that can stall the real-time thread, and you can see that just by reading the signatures. The exhaustive switch on Waveform and on the envelope Stage means the state machine can never fall through an unhandled case. And undefined plus an init loop gives us a pool that lives wherever the Synth lives, with no heap and no teardown.

In C, this same synth is entirely writable -- it is how most real ones are written -- but the compiler helps you less: the waveform switch has no exhaustiveness check, an uninitialised voice is undefined behaviour rather than a caught mistake, and float clipping is a silent corruption you discover with your ears. In Rust, the shape is strikingly close to ours: an array of Voice, an enum for the stage matched exhaustively, no allocation on the hot path -- the design Zig encourages is the one Rust enforces, which is again a comforting sign we are building it right. In Go, you would get memory safety for free but pay for a garbage collector on a workload that touches zero heap, and worse, a GC pause is the exact millisecond-scale stall that produces an audible glitch -- which is why you rarely see Go in a serious audio callback. Zig lands where it always does: the directness and predictability of C, the edge-safety instincts of Rust, and you still deciding where every byte lives.

What we built, and what comes next

Step back and look at the machine. A noteToHz bridge from the musician's world to the numeric one. An Oscillator that is pure pitch. An Envelope that is pure shape. A Voice that multiplies the two and knows when it is done. And a Synth that pools voices, allocates them on noteOn, releases them on noteOff, and mixes them on render -- all without touching the heap once the pool exists. It plays chords, it holds notes, it lets them fade, and every bit of it is proven correct against a plain array of floats with no soundcard in sight.

That is the same functional-core / imperative-shell shape the whole series keeps returning to: a pure, tested core, with the noisy real world -- the actual audio device from episode 170 -- waiting to be bolted on at the very edge. Next time we push on the parts this first cut left deliberately crude: what happens when all sixteen voices are busy and a seventeenth note arrives, and how to make the sound richer than a single raw waveform. Same discipline, more music.

Thanks for reading this one -- tot de volgende keer! ;-)

@scipio



0
0
0.000
0 comments