Learn Zig Series (#172) - Synthesis: Envelopes and Filters

Words
5052
Reading
23 min
Listen
Play
11h

Learn Zig Series (#172) - Synthesis: Envelopes and Filters

zig.png

What will I learn?

  • What an envelope is, and why a note needs one -- how ADSR (attack, decay, sustain, release) turns a lifeless drone into something that actually plays;
  • How to model the envelope as an explicit state machine with a tagged enum (episodes 6 and 33), so every stage is exhaustive and impossible to forget;
  • How to gate a voice on and off, and why the release stage is the piece almost everyone gets wrong the first time;
  • What a filter really does to a sound, why subtractive synthesis starts from a harmonically-rich saw, and how a one-pole low-pass works from first principles;
  • How to build a resonant state-variable filter with independent cutoff and resonance -- the knob that makes a synth sing;
  • How to test envelopes and filters deterministically with no sound card in sight, and the honest performance rules for the audio hot path;
  • Where C, Rust and Go land on the same design, and where Zig's exhaustive switch and comptime quietly pay 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 oscillator, phase accumulator and PolyBLEP from episode 171, plus the PCM buffer (episode 169) and the real-time callback (episode 170);
  • Comfort with structs, enums and tagged unions (episode 6), the state-machine pattern (episode 33), 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 (#172) - Synthesis: Envelopes and Filters

Last episode we built the oscillator -- one little phase accumulator that can be a sine, a saw, a square or a triangle, at any pitch, with the aliasing shaved off. It makes a sound. But it makes exactly one sound, forever: hold the key and it drones on, flat and lifeless, the same the millisecond you press it as the minute after. A real instrument does not do that. A plucked string leaps up loud and dies away. A bowed note swells in. A brass stab is bright at the front and mellows as it holds. Two things create almost all of that life: how the note's loudness moves over time (the envelope), and how its harmonics are carved (the filter). Today we build both, and by the end that drone finally becomes something you can call a note. Here we go!

But first, the three loose ends I left you with at the end of the oscillator episode.

Solutions to Episode 171 Exercises

Exercise 1 -- a pulse-width knob. The square wave is just the special case of a pulse where the high part and the low part are equal. Generalize it with a duty field: output +1 while the phase is below duty, else -1. A duty of 0.5 is the square; slide it down toward 0.05 and the tone thins out into a nasal, reedy buzz, because you are changing which harmonics are present. The test pins the meaning of duty precisely -- at 0.25, exactly a quarter of the samples in one cycle are high:

const std = @import("std");

pub const Pulse = struct {
    phase: f32 = 0.0,
    increment: f32,
    duty: f32 = 0.5, // fraction of the cycle spent high

    pub fn init(freq: f32, sample_rate: u32) Pulse {
        return .{ .increment = freq / @as(f32, @floatFromInt(sample_rate)) };
    }

    pub fn next(self: *Pulse) f32 {
        const value: f32 = if (self.phase < self.duty) 1.0 else -1.0;
        self.phase += self.increment;
        if (self.phase >= 1.0) self.phase -= 1.0;
        return value;
    }
};

test "a duty of 0.25 spends a quarter of each cycle high" {
    var p = Pulse.init(480.0, 48_000); // 100 samples per cycle
    p.duty = 0.25;
    var high: usize = 0;
    for (0..100) |_| {
        if (p.next() > 0.0) high += 1;
    }
    try std.testing.expectEqual(@as(usize, 25), high);
}

Exercise 2 -- detune two saws. Run two sawtooths a few cents apart (a cent is a hundredth of a semitone), sum them, and halve. Because the two waves have almost the same period but not quite, they slide slowly in and out of phase: where the peaks line up they reinforce, where they oppose they cancel, and that slow rise-and-fall of loudness is the beating you hear -- at a rate equal to the difference between the two frequencies. It is the whole reason a "supersaw" sounds fat and alive in stead of thin and static:

const std = @import("std");

/// Two saws a few cents apart, summed and halved -- the classic "supersaw" fatness.
pub const DetunedPair = struct {
    a: Oscillator,
    b: Oscillator,

    pub fn init(freq: f32, cents: f32, sample_rate: u32) DetunedPair {
        const ratio = std.math.pow(f32, 2.0, cents / 1200.0); // cents -> frequency ratio
        var a = Oscillator.init(freq, sample_rate);
        var b = Oscillator.init(freq * ratio, sample_rate);
        a.waveform = .saw;
        b.waveform = .saw;
        return .{ .a = a, .b = b };
    }

    pub fn next(self: *DetunedPair) f32 {
        return 0.5 * (self.a.next() + self.b.next());
    }
};

test "summed detuned saws stay inside -1..1" {
    var pair = DetunedPair.init(220.0, 7.0, 48_000); // 7 cents apart
    for (0..4096) |_| {
        const s = pair.next();
        try std.testing.expect(s >= -1.0 and s <= 1.0);
    }
}

Two values each in -1..1, averaged, can never leave -1..1 -- so the halving is not cosmetic, it is the thing that guarantees no clipping. Notice we reused the Oscillator from last episode wholesale; the "supersaw" is not a new oscillator, it is two old ones and a plus sign.

Exercise 3 -- a wavetable from your own shape. The naive saw buzzes because it carries an infinite stack of harmonics that fold back. So build one that cannot: add up only a handful of harmonics by hand -- sin(x) + sin(2x)/2 + sin(3x)/3 + ... -- and bake the result into a table at comptime (episode 9). Because nothing above the 16th harmonic exists in the table, there is nothing high enough to alias at moderate pitches, so it stays clean where the naive saw grits:

const std = @import("std");

/// One cycle of a band-limited saw: sum of harmonics (1/k)*sin(k*x), built at COMPILE time.
pub fn harmonicSawTable(comptime n: usize, comptime harmonics: usize) [n]f32 {
    var t: [n]f32 = undefined;
    for (0..n) |i| {
        const x = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(n)) * 2.0 * std.math.pi;
        var acc: f32 = 0.0;
        var k: usize = 1;
        while (k <= harmonics) : (k += 1) {
            const kf = @as(f32, @floatFromInt(k));
            acc += @sin(kf * x) / kf; // the k-th harmonic at 1/k amplitude
        }
        t[i] = acc;
    }
    return t;
}

const saw_table = harmonicSawTable(1024, 16); // 16 harmonics, computed by the compiler

Drive last episode's WavetableOsc from &saw_table and compare it by ear (or by a spectrum) against the naive ramp. The difference is night and day at high notes. This is really additive synthesis meeting wavetable synthesis -- we construct the band-limited shape from harmonics, then read it back cheaply. Right -- three loose ends tied. Now let us make the note breathe.

An oscillator drones; an envelope makes it a note

Here is the core idea, and it is almost embarrassingly simple: an envelope is just a number between 0.0 and 1.0 that changes over time, and you multiply the oscillator's output by it. When the envelope is 1.0 the note is at full volume; when it is 0.0 the note is silent; the shape of how it travels between those is what your ear reads as "pluck", "swell", "stab" or "pad".

The classic model, in synths since the 1960s, is ADSR -- four segments:

  • Attack: the time to rise from 0.0 (silence, the moment you press the key) up to 1.0 (peak). A short attack is a percussive snap; a long attack is a slow swell.
  • Decay: the time to fall from that peak down to the sustain level.
  • Sustain: not a time but a level -- the volume the note holds at for as long as you keep the key down.
  • Release: the time to fall from wherever it currently is back down to 0.0, once you let go of the key.

That last distinction trips up almost everyone: attack, decay and release are durations, but sustain is a level. A note can sit in the sustain stage for a tenth of a second or a full minute, entirely depending on how long you hold the key. That is why an envelope cannot be a fixed pre-computed curve -- it has to react to a gate signal (key down, key up) that arrives whenever the player decides.

The ADSR envelope as a state machine

Because the envelope reacts to events and moves through named stages, it is a state machine -- exactly the pattern from episode 33, and a perfect fit for a Zig tagged enum. Each sample, we look at which stage we are in, nudge the level toward that stage's target, and switch stages when we arrive. Modeling the stage as an enum means the switch is exhaustive: if I ever add a stage and forget to handle it, the compiler refuses to build. That is the whole safety argument for enums-as-states, and it is why I reach for one here in stead of a bare integer or a pile of booleans:

const std = @import("std");

pub const Stage = enum { idle, attack, decay, sustain, release };

pub const Adsr = struct {
    stage: Stage = .idle,
    level: f32 = 0.0, // current envelope value, 0..1

    // Per-sample increments, precomputed from times and the sample rate.
    attack_rate: f32,
    decay_rate: f32,
    sustain_level: f32,
    release_rate: f32,

    /// Convert a stage TIME in seconds into a per-sample step over a 0..1 span.
    fn ratePerSample(seconds: f32, sample_rate: u32) f32 {
        if (seconds <= 0.0) return 1.0; // instantaneous stage
        return 1.0 / (seconds * @as(f32, @floatFromInt(sample_rate)));
    }

    pub fn init(a: f32, d: f32, s: f32, r: f32, sample_rate: u32) Adsr {
        return .{
            .attack_rate = ratePerSample(a, sample_rate),
            .decay_rate = ratePerSample(d, sample_rate),
            .sustain_level = std.math.clamp(s, 0.0, 1.0),
            .release_rate = ratePerSample(r, sample_rate),
        };
    }
};

Notice how much work happens once in init: every stage time is converted from human-friendly seconds into a per-sample increment. The audio thread should never divide or call anything expensive per sample if it can help it, so we pay that cost up front. This is the same "hoist the constants" instinct from the oscillator's hot path -- pushed all the way into construction.

Now the gate and the per-sample tick. Pressing a key calls noteOn, which jumps us into attack; releasing calls noteOff, which jumps to release from wherever we are (this is the crucial part -- you can release a note mid-attack, and it must glide down from its current level, not snap):

pub fn noteOn(self: *Adsr) void {
    self.stage = .attack;
}

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

/// Advance one sample and return the current envelope value (0..1).
pub fn next(self: *Adsr) f32 {
    switch (self.stage) {
        .idle => self.level = 0.0,
        .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;
            }
        },
        .sustain => self.level = self.sustain_level, // hold until noteOff
        .release => {
            self.level -= self.release_rate;
            if (self.level <= 0.0) {
                self.level = 0.0;
                self.stage = .idle;
            }
        },
    }
    return self.level;
}

Read it stage by stage and there is nothing mysterious: each arm moves level toward that stage's target and, on arrival, transitions to the next stage. The idle stage is the resting state -- silent, and (importantly) the signal that a voice is free to be reused, which matters the moment you build polyphony. Linear segments like these are the honest starting point; real synths often use exponential curves (a decay that slows as it falls sounds more natural, because that is how physical things lose energy), and that is exactly one of tonight's exercises.

Wiring the envelope to the oscillator

A voice is an oscillator and an envelope glued together: ask the oscillator for a raw sample, ask the envelope for its current level, multiply. That multiply is the entire connection -- amplitude modulation at its most basic:

pub const Voice = struct {
    osc: Oscillator,
    env: Adsr,

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

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

    pub fn next(self: *Voice) f32 {
        return self.osc.next() * self.env.next(); // raw tone shaped by loudness
    }

    pub fn isFinished(self: *const Voice) bool {
        return self.env.stage == .idle;
    }
};

Drop Voice.next() into last episode's producer in place of the bare osc.next(), wire a key press to noteOn and a key release to noteOff, and the drone is gone -- now the note swells, holds and dies exactly as you shaped it. isFinished is the small but vital hook: a voice that has fallen back to idle produces pure silence and can be handed out to the next note, which is how a synth plays more than one thing at a time without an unbounded pile of voices. Having said that, we are still only shaping loudness. To shape tone -- to make a bright note go dark as it decays, the sound almost every analog synth is built on -- we need the other half of today: the filter.

Filters: carving harmonics with subtractive synthesis

The dominant style of synthesis for fifty years is subtractive: start with a harmonically rich waveform (our saw or square, which is stuffed with harmonics), then remove some of them with a filter to sculpt the tone. A low-pass filter lets frequencies below a cutoff through and attenuates the ones above it -- so sweeping the cutoff down on a saw gradually strips the top harmonics and the sound goes from bright and buzzy to dark and round. That cutoff sweep, especially with an envelope driving it, is the signature gesture of electronic music.

The simplest useful filter is the one-pole low-pass, and it is almost suspiciously small. Each sample, you move the output a little bit of the way from where it currently is toward the new input. That "little bit" is a coefficient derived from the cutoff frequency: a low cutoff means small steps (sluggish, so it cannot follow fast wiggles -- i.e. high frequencies -- which is exactly what "low-pass" means), a high cutoff means large steps:

const std = @import("std");

pub const OnePole = struct {
    prev: f32 = 0.0, // last output (the filter's memory)
    alpha: f32, // smoothing coefficient, 0..1

    /// alpha from a cutoff frequency: the standard RC-filter mapping.
    pub fn init(cutoff_hz: f32, sample_rate: u32) OnePole {
        const dt = 1.0 / @as(f32, @floatFromInt(sample_rate));
        const rc = 1.0 / (2.0 * std.math.pi * cutoff_hz);
        return .{ .alpha = dt / (rc + dt) };
    }

    pub fn process(self: *OnePole, input: f32) f32 {
        self.prev += self.alpha * (input - self.prev); // step toward the input
        return self.prev;
    }
};

That single line -- prev += alpha * (input - prev) -- is a genuine, working low-pass filter, and it is worth staring at until it clicks. When alpha is near 1.0 the output snaps almost instantly to the input (nothing is filtered, cutoff is high); when alpha is tiny the output crawls, smoothing out anything fast (a low cutoff). It is the same exponential-smoothing formula you would use to average a noisy sensor reading -- audio filtering and data smoothing are, at the bottom, the same maths. The one weakness: a one-pole rolls off gently (6 dB per octave) and has no resonance -- no emphasis right at the cutoff. For a synth that can actually sing, we want more.

A filter worth playing: the state-variable filter

The filter that made synthesizers expressive has a resonance control: a peak of emphasis right at the cutoff frequency that, turned up high, whistles and eventually self-oscillates. A compact, classic way to get one is the state-variable filter (the Chamberlin form), which is lovely because it computes low-pass, high-pass and band-pass outputs all at once from two little state registers, with independent cutoff and resonance:

const std = @import("std");

pub const Svf = struct {
    low: f32 = 0.0,
    band: f32 = 0.0,
    f: f32, // frequency coefficient
    q: f32, // damping (1/resonance): lower q = sharper peak

    pub fn init(cutoff_hz: f32, resonance: f32, sample_rate: u32) Svf {
        const sr = @as(f32, @floatFromInt(sample_rate));
        // Chamberlin is stable while cutoff stays below ~sr/6; clamp to be safe.
        const fc = @min(cutoff_hz, sr / 6.0);
        return .{
            .f = 2.0 * @sin(std.math.pi * fc / sr),
            .q = 1.0 / @max(resonance, 0.5), // resonance >= 0.5 keeps it stable
        };
    }

    /// Returns the low-pass output; high and band are available too.
    pub fn process(self: *Svf, input: f32) f32 {
        self.low += self.f * self.band;
        const high = input - self.low - self.q * self.band;
        self.band += self.f * high;
        return self.low;
    }
};

Three lines of update, two state registers, and you have the filter at the heart of countless classic synths. The q term is the resonance: it is the damping, so a smaller q means a sharper, louder peak at the cutoff (which is why I invert resonance to get it, and clamp so a careless caller cannot make it blow up). Sweep f with an ADSR envelope -- literally feed the envelope value into cutoff -- and you get the "wah", the "pluck", the bright-attack-into-dark-sustain that is the sound of subtractive synthesis. That is the same envelope we built above, pointed at tone in stead of loudness. One idea, two jobs.

Where Zig's type system and errors quietly help

None of this is complicated code, but it is code where a silently-wrong number becomes an audible defect -- a click, a blown-up filter, a note that never releases. Zig's insistence on the explicit is a real asset here. The exhaustive switch on Stage means a forgotten envelope stage is a compile error, not a stuck note. And when a filter takes user-facing parameters, Zig's error unions (episode 4) let us reject nonsense at the door in stead of returning a NaN that quietly poisons the whole audio buffer downstream:

const std = @import("std");

pub const FilterError = error{ CutoffTooLow, CutoffAboveNyquist };

/// Validate a cutoff before building a filter, rather than trusting the caller.
pub fn checkedCutoff(cutoff_hz: f32, sample_rate: u32) FilterError!f32 {
    if (cutoff_hz <= 0.0) return error.CutoffTooLow;
    const nyquist = @as(f32, @floatFromInt(sample_rate)) / 2.0;
    if (cutoff_hz >= nyquist) return error.CutoffAboveNyquist;
    return cutoff_hz;
}

test "cutoff validation rejects the impossible" {
    try std.testing.expectError(error.CutoffTooLow, checkedCutoff(-20.0, 48_000));
    try std.testing.expectError(error.CutoffAboveNyquist, checkedCutoff(30_000.0, 48_000));
    try std.testing.expectEqual(@as(f32, 1000.0), try checkedCutoff(1000.0, 48_000));
}

The point is not that this particular check is profound -- it is that Zig makes the failure path visible and typed. A cutoff above Nyquist (episode 171) is physically meaningless; catching it as a named error at construction is far kinder than a filter that goes unstable three seconds into playback and screams. Nota bene: the audio callback itself must stay error-free and allocation-free, so this kind of validation belongs at setup time, on the control thread -- never inside the per-sample loop.

Testing envelopes and filters with no speaker

Just like the oscillator, both of these are pure, deterministic functions of their state, so we test the numbers and leave the sound card out of it entirely. An envelope has properties that must hold: attack really does reach the top, and after a release it really does return to exactly zero and go idle. Here is the full life-cycle in one test:

test "adsr rises to peak, sustains, and releases to silence" {
    var env = Adsr.init(0.01, 0.01, 0.5, 0.01, 48_000); // 10ms stages, 0.5 sustain
    env.noteOn();

    // Run well past attack+decay: we must settle at the sustain level.
    for (0..2000) |_| _ = env.next();
    try std.testing.expectApproxEqAbs(@as(f32, 0.5), env.next(), 1e-3);

    // Release, run past it: must land exactly on silence and go idle.
    env.noteOff();
    for (0..2000) |_| _ = env.next();
    try std.testing.expectEqual(@as(f32, 0.0), env.next());
    try std.testing.expectEqual(Stage.idle, env.stage);
}

A filter's defining property is just as checkable without ears: a low-pass must pass a DC (constant) input through untouched once it settles -- zero is very much a frequency below the cutoff -- while heavily attenuating something that flips every sample (the highest frequency a sampled system can hold, right at Nyquist). Feed it both and assert:

test "one-pole passes DC and attenuates the fastest wiggle" {
    var lp = OnePole.init(1000.0, 48_000);
    // A constant 1.0 must settle to ~1.0 (DC sails through a low-pass).
    var y: f32 = 0.0;
    for (0..10_000) |_| y = lp.process(1.0);
    try std.testing.expectApproxEqAbs(@as(f32, 1.0), y, 1e-2);

    // A +1,-1,+1,... input is the fastest signal; its output must be tiny.
    var lp2 = OnePole.init(1000.0, 48_000);
    var peak: f32 = 0.0;
    for (0..10_000) |i| {
        const x: f32 = if (i % 2 == 0) 1.0 else -1.0;
        peak = @max(peak, @abs(lp2.process(x)));
    }
    try std.testing.expect(peak < 0.2); // strongly attenuated
}

These are the mistakes people actually ship: a filter coefficient with the wrong sign (DC drifts instead of settling), an envelope that overshoots 1.0 and clicks, a release that stops just shy of zero and leaves a faint tail. Every one of them is caught here, on the CPU, in microseconds, with no listening involved. This is episode 12's discipline made concrete for audio for the second episode running -- and it keeps paying off.

Performance: envelopes and filters on the hot path

Both an envelope and a filter run per sample, often once per voice per sample, so with a chord of voices this is squarely on the audio hot path (episode 170's commandment: the callback must always finish in time). A few habits keep them cheap:

  • Precompute coefficients on the control thread. Every @sin, division and reciprocal here happens in init, never in process/next. When the player turns a cutoff knob, recompute f once for that block -- not once per sample.
  • Keep the per-sample body branch-light. The envelope's switch is unavoidable, but it is a cheap jump; the filters have no branches at all, just multiply-adds, which is exactly what a CPU (and a vectorizer) loves.
  • Watch the denormals. When a release tail decays toward zero, the numbers can get extremely small and slide into denormal floats, which on some CPUs are dramatically slower to compute. The pragmatic fix is a tiny flush: once the level is below something like 1e-6, snap it to 0.0 and go idle -- which our envelope already does at the end of release.
  • Measure before reaching for SIMD (episode 34). A single voice is trivially cheap; only when you are mixing dozens does a profiler earn you the right to vectorize a filter across voices. For the envelope's state machine, SIMD barely helps anyway -- the branchy filters and the oscillator are the better targets.

The structural theme is identical to last episode: do the expensive, human-facing maths once at setup, and let the per-sample loop be nothing but a handful of cheap arithmetic ops feeding the ring.

The same job in C, Rust and Go

The envelope-as-state-machine and the filter-as-difference-equation are universal -- the maths is the maths in every language. What differs is how safely each one lets you package the stage machine. In C, the ADSR is the same switch on an enum, but nothing forces you to handle every case -- forget the release arm and the compiler shrugs; you find out when a note hangs:

// C: same ADSR switch -- but a missing case is silently allowed.
float adsr_next(Adsr* e) {
    switch (e->stage) {
        case ATTACK:
            e->level += e->attack_rate;
            if (e->level >= 1.0f) { e->level = 1.0f; e->stage = DECAY; }
            break;
        case DECAY:
            e->level -= e->decay_rate;
            if (e->level <= e->sustain) { e->level = e->sustain; e->stage = SUSTAIN; }
            break;
        /* forget RELEASE here and gcc says nothing */
        default: break;
    }
    return e->level;
}

Rust models the stage as an enum and matches on it, and -- like Zig -- the match is exhaustive, so a forgotten stage is a compile error. That is the same guarantee Zig gives, arrived at from the safety-first direction:

// Rust: an exhaustive match -- a forgotten stage won't compile.
fn next(&mut self) -> f32 {
    match self.stage {
        Stage::Attack => {
            self.level += self.attack_rate;
            if self.level >= 1.0 { self.level = 1.0; self.stage = Stage::Decay; }
        }
        Stage::Decay => { /* ... */ }
        Stage::Sustain => self.level = self.sustain_level,
        Stage::Release => { /* ... */ }
        Stage::Idle => self.level = 0.0,
    }
    self.level
}

Go would use a small struct and an integer or typed constant for the stage, but its switch is not exhaustive-checked either, and its garbage collector is the real story on the audio thread: you engineer hard to keep every voice allocation-free so the GC never pauses mid-callback, the same pull-model glitch risk we designed around in episode 170. Where does Zig land? Our next() is as blunt and fast as the C, its switch is as safe as the Rust match, and coefficients bake at comptime or in init with no runtime tax and no collector to dodge. That is the trade this whole audio arc keeps demonstrating -- C's directness and Rust's safety, without having to pick one.

Exercises

  1. Exponential envelope segments. Replace the linear attack/decay/release with exponential curves: in stead of adding a fixed rate, move the level a fraction of the remaining distance to the target each sample (level += coeff * (target - level)), the same shape as the one-pole filter. Add a test that the decay from 1.0 toward a 0.3 sustain is monotonically falling and settles near 0.3. Which sounds more natural to you, and why?

  2. An envelope-driven filter sweep. Give a Voice a second Adsr and use its output to modulate an Svf cutoff each sample (map the 0..1 envelope onto, say, 200 Hz .. 6000 Hz). Play a saw through it with a fast attack and slow decay -- the classic "pluck". Write a test that the cutoff coefficient stays within its stable range across the whole sweep.

  3. A high-pass from the state-variable filter. The Svf already computes a high value internally -- expose it. Then chain a low-pass and a high-pass to build a band-pass by hand, and test that a DC (constant) input is blocked by the high-pass (settles to ~0) while a mid-frequency tone passes. Compare your hand-built band-pass to the SVF's own band output.

What we learned

  • An envelope is nothing but a 0..1 number that changes over time, and you multiply the oscillator by it -- that single multiply is what turns a flat drone into a note that swells, holds and dies;
  • ADSR has three times (attack, decay, release) and one level (sustain), and because it reacts to a gate it must be a state machine -- a Zig tagged enum with an exhaustive switch, so a forgotten stage cannot compile;
  • Subtractive synthesis starts from a harmonically rich saw or square and removes harmonics with a filter; a one-pole low-pass is a single line (prev += alpha * (input - prev)), the very same exponential smoothing you would use on noisy data;
  • A state-variable filter adds the missing ingredient -- resonance -- giving independent cutoff and emphasis from two state registers, and sweeping its cutoff with an envelope is the signature electronic-music gesture;
  • Zig's exhaustive switch and typed errors turn silent audio bugs into compile errors and named failures at setup time, while the per-sample loop stays branch-light and allocation-free on the hot path (measure first, episode 34);
  • Envelopes and filters are pure, deterministic functions of their state, so you test the numbers, not the sound -- an attack-sustain-release life-cycle check and a DC-passes/high-frequency-attenuates check catch the real bugs with no speaker involved.

So the voice is complete: an oscillator for the raw tone, an envelope to shape its loudness, a filter to carve its harmonics. Press a key and it lives -- swells, holds, brightens, darkens, dies. But one voice is a solo. The moment you want a chord, or two players at once, or a note that keeps ringing while you start the next, you run head-first into the question of how many voices sound at once and how you share the mix between them without clipping the output to pieces. That is where we head next, and it is where a single voice grows into an instrument. Plenty still to build, and it only gets more fun from here.

Thanks for reading -- de groeten, en tot de volgende! ;-)

scipio@scipio

Learn Zig Series (#172) - Synthesis: Envelopes and Filters | Ecency