Learn Zig Series (#170) - Audio Output via C Interop

Words
5012
Reading
23 min
Listen
Play
17h

Learn Zig Series (#170) - Audio Output via C Interop

zig.png

What will I learn?

  • Why a real audio device is a pull model, not a push model -- the sound card calls you on a strict clock, and what that does to how you structure the whole program;
  • How to bind a real C audio library (miniaudio) with the zero-cost @cImport interop from episodes 27 and 28, and wire it up in build.zig;
  • How to hand your own Zig state to a C callback safely through the pUserData pointer, and why the callback signature has to be callconv(.c);
  • How to feed a live callback from a background thread through the lock-free ring buffer from episode 113 -- and why an underrun must degrade to silence, never to a block;
  • How to wrap a C library's integer result codes into a proper Zig error set, so the rest of your program stays idiomatic;
  • How to test audio-output code when the CI box has no speaker, plus the honest hot-path rules and how C, Rust and Go reach the same device.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Zig 0.14+ distribution (download from ziglang.org) -- the code here is written against Zig 0.16;
  • The PCM buffer, sine generator and WAV code from episode 169, the C interop from episodes 27 and 28, the lock-free ring buffer from episode 113, and the atomics discipline from episode 30;
  • The build-system basics from episode 15 to link a C source file;
  • The ambition to learn Zig programming.

Difficulty

  • Advanced

Curriculum (of the Learn Zig Series):

Learn Zig Series (#170) - Audio Output via C Interop

Last episode we built real, playable sound from nothing but numbers and a 44-byte header -- a sine wave into a buffer, out to a WAV file, back in again. But I ended on an itch: the only way to hear any of it was to open the file in a media player. Today we scratch that itch. We are going to push those samples straight at the speaker, in real time, while the program runs. And to do that we finally cash in the C interop we sharpened in episodes 27 and 28, because talking to a physical audio device is not something you do from scratch in a tutorial -- the operating system audio APIs (WASAPI on Windows, CoreAudio on macOS, ALSA/PulseAudio/PipeWire on Linux) are a swamp, and the sane move is to bind one small cross-platform C library and let it do the platform dance. Here we go!

But first, the three loose ends from the PCM episode.

Solutions to Episode 169 Exercises

Exercise 1 -- stereo panning with equal power. The naive pan (left = 1 - pan, right = pan) has a nasty flaw: at the center the total energy dips, so a sound sweeping across the stereo field seems to get quieter in the middle. Equal-power panning fixes it by mapping the pan position onto an angle and using cos/sin, whose squares always sum to one -- constant power everywhere:

const std = @import("std");

/// Fill an interleaved stereo AudioBuffer with a panned sine tone.
/// pan: -1.0 = hard left, 0.0 = center, +1.0 = hard right.
pub fn fillSineStereo(buf: *AudioBuffer, freq: f32, amplitude: f32, pan: f32) void {
    std.debug.assert(buf.channels == 2);
    const two_pi = 2.0 * std.math.pi;
    const sr: f32 = @floatFromInt(buf.sample_rate);
    // Map pan (-1..1) onto an angle 0..pi/2, then split with cos/sin.
    const theta = (std.math.clamp(pan, -1.0, 1.0) + 1.0) * 0.25 * std.math.pi;
    const left_gain = @cos(theta);
    const right_gain = @sin(theta);
    for (0..buf.frameCount()) |n| {
        const t = @as(f32, @floatFromInt(n)) / sr;
        const s = amplitude * @sin(two_pi * freq * t);
        const fr = buf.frame(n);
        fr[0] = s * left_gain;
        fr[1] = s * right_gain;
    }
}

test "center pan is equal, hard pans silence one side" {
    var buf = try AudioBuffer.init(std.testing.allocator, 2, 48_000, 512);
    defer buf.deinit();
    fillSineStereo(&buf, 440.0, 0.8, 0.0);
    const mid = buf.frame(64);
    try std.testing.expectApproxEqAbs(mid[0], mid[1], 1e-5); // center: equal
    fillSineStereo(&buf, 440.0, 0.8, -1.0);
    try std.testing.expect(@abs(buf.frame(64)[1]) < 1e-5);   // hard left: right silent
}

At the center theta is pi/4, so both gains are sqrt(1/2) and the two channels are equal but each about 0.707 of the source -- and 0.707^2 + 0.707^2 == 1, which is exactly the "power stays put" property we were after. Hard left is theta == 0 (cos = 1, sin = 0), hard right is theta == pi/2. Notice I reused the frame(i) slice accessor from last episode: writing fr[0] and fr[1] is the interleaved layout paying rent again.

Exercise 2 -- a fade envelope. A buffer that starts or ends on a non-zero sample makes the speaker jump instantly, and an instant jump is a high-frequency pop. A short linear ramp at each end kills it:

const std = @import("std");

/// Ramp amplitude up over the first fade_ms and down over the last fade_ms.
pub fn applyFade(samples: []f32, sample_rate: u32, fade_ms: f32) void {
    const fade_frames: usize = @intFromFloat(@as(f32, @floatFromInt(sample_rate)) * fade_ms / 1000.0);
    const n = samples.len;
    if (fade_frames == 0 or n == 0) return;
    const ramp = @min(fade_frames, n / 2); // never overlap the two ramps
    for (0..ramp) |i| {
        const g: f32 = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(ramp));
        samples[i] *= g;           // fade in
        samples[n - 1 - i] *= g;   // fade out
    }
}

test "fade zeroes the endpoints and leaves the middle intact" {
    var samples = [_]f32{1.0} ** 1000;
    applyFade(&samples, 48_000, 5.0); // ~240 frames each side
    try std.testing.expect(samples[0] < 0.01);
    try std.testing.expect(samples[999] < 0.01);
    try std.testing.expectApproxEqAbs(@as(f32, 1.0), samples[500], 1e-6);
}

The @min(fade_frames, n / 2) guard is the detail people skip: on a very short buffer, two long ramps would overlap and multiply the middle twice, which is wrong. Clamping the ramp length to half the buffer means the two fades meet in the middle at worst and never cross. Linear is the honest starting point; a cosine ("equal-power") fade sounds smoother, and that was the stretch goal.

Exercise 3 -- a robust WAV reader that walks the chunk list. Last episode's reader hard-coded the 44-byte layout, which breaks the moment a file wedges a LIST chunk before data. The real shape is a loop over (id, length, payload) triples, capturing fmt and data wherever they land:

const std = @import("std");

pub fn readWavChunked(allocator: std.mem.Allocator, bytes: []const u8) !LoadedWav {
    if (bytes.len < 12) return WavError.Truncated;
    if (!std.mem.eql(u8, bytes[0..4], "RIFF") or !std.mem.eql(u8, bytes[8..12], "WAVE"))
        return WavError.BadMagic;

    var spec: ?AudioSpec = null;
    var data: ?[]const u8 = null;
    var i: usize = 12;
    while (i + 8 <= bytes.len) {
        const id = bytes[i .. i + 4];
        const len = std.mem.readInt(u32, bytes[i + 4 ..][0..4], .little);
        i += 8;
        if (i + len > bytes.len) return WavError.Truncated;
        const payload = bytes[i .. i + len];
        if (std.mem.eql(u8, id, "fmt ")) {
            if (len < 16) return WavError.Truncated;
            const fmt = std.mem.readInt(u16, payload[0..2], .little);
            const bits = std.mem.readInt(u16, payload[14..16], .little);
            if (fmt != 1 or bits != 16) return WavError.Unsupported;
            spec = .{
                .channels = @intCast(std.mem.readInt(u16, payload[2..4], .little)),
                .sample_rate = std.mem.readInt(u32, payload[4..8], .little),
                .bits_per_sample = bits,
            };
        } else if (std.mem.eql(u8, id, "data")) {
            data = payload;
        }
        i += len + (len & 1); // chunks are padded to an even length
    }

    const s = spec orelse return WavError.Unsupported;
    const d = data orelse return WavError.Truncated;
    const count = d.len / 2;
    const samples = try allocator.alloc(i16, count);
    errdefer allocator.free(samples);
    for (0..count) |k| samples[k] = std.mem.readInt(i16, d[k * 2 ..][0..2], .little);
    return .{ .spec = s, .samples = samples, .allocator = allocator };
}

The len + (len & 1) is the padding rule -- RIFF chunks are word-aligned, so an odd-length payload is followed by one throwaway byte, and forgetting it desyncs you by one and turns the next chunk id into garbage. This is exactly the marker-walk pattern from the JPEG episode, one level of formality up: don't assume offsets, find your chunks. Right, the file side is done. Now let us make noise in real time.

The audio device is a pull model

Here is the mental shift that everything today hangs on. When you wrote a WAV, you were in charge: you decided when to write each sample, and the file waited patiently. A live audio device is the opposite. The sound card runs on its own clock, and every few milliseconds it turns to your program and says "give me the next N frames, right now." You do not call it -- it calls you. This is a pull (or callback) model, and it is not optional: the hardware consumes samples at a fixed rate whether you are ready or not, and if you are late, the listener hears a glitch (a click or a dropout). There is no "render it a bit late" grace like graphics sometimes has, because time does not stop for audio.

That single fact -- the device pulls on a strict clock -- drives the design. You register a callback function with the audio library. The library spins up a high-priority audio thread, and on that thread it calls your function again and again, handing you an output buffer to fill. Your job is to fill it with the next block of samples and return, fast, every single time. Which means the callback lives under the strict hot-path rules we met last episode: no allocating, no locking against a slow thread, no filesystem, nothing that can block. We will honor those rules by preparing samples elsewhere and handing them over cheaply. But first, the plumbing: how do we even get a C library to call a Zig function?

Binding miniaudio with @cImport

I am going to use miniaudio -- a single-header C library that speaks WASAPI, CoreAudio and ALSA/PulseAudio behind one tiny API. Single-header C is the friendliest possible interop target (we saw why in episode 27): one @cInclude and Zig generates the whole binding for us. The only wrinkle is that miniaudio, like many single-header libs, needs one translation unit to #define MINIAUDIO_IMPLEMENTATION before including the header, which is what actually compiles the library body:

const std = @import("std");

const c = @cImport({
    @cDefine("MINIAUDIO_IMPLEMENTATION", "");
    @cInclude("miniaudio.h");
});

That c namespace now holds every miniaudio type and function -- c.ma_device, c.ma_device_config, c.ma_device_init, c.MA_SUCCESS, the lot -- translated into Zig types by the same machinery that has served us since episode 27. To make it link, we compile the single-header's implementation as a C source file and pull in libc from build.zig (episode 15). One extra .c shim that does nothing but define the implementation macro and include the header keeps the heavy compile out of the @cImport:

// build.zig -- compile miniaudio's implementation once and link libc.
const exe = b.addExecutable(.{
    .name = "player",
    .root_source_file = b.path("src/main.zig"),
    .target = target,
    .optimize = optimize,
});
exe.addIncludePath(b.path("vendor/miniaudio"));
exe.addCSourceFile(.{
    .file = b.path("vendor/miniaudio/miniaudio_impl.c"),
    .flags = &.{"-std=c99"},
});
exe.linkLibC(); // miniaudio needs the C runtime and OS audio libs
b.installArtifact(exe);

On Linux you would also link the platform backend (-lpthread -lm -ldl and friends), which miniaudio documents per target; linkLibC plus the system's default libraries covers the common case. The point I want to land is that from Zig's side this is boring -- the same include-and-link recipe as any C dependency, which is precisely the promise of zero-cost interop.

The simplest possible playback

Let us make a tone. The callback miniaudio expects has a fixed C signature, and the critical keyword is callconv(.c): our function has to use the C calling convention so the C library can call it correctly across the language boundary (episode 28 hammered this -- a Zig-convention function passed to C is a crash waiting to happen). We keep the oscillator's running phase in a small struct and advance it per sample, exactly the phase-accumulator idiom I promised last episode:

const SineState = struct {
    phase: f32 = 0.0,
    increment: f32, // 2*pi*freq / sample_rate
    amplitude: f32 = 0.2,
};

fn dataCallback(
    device: ?*c.ma_device,
    output: ?*anyopaque,
    input: ?*const anyopaque,
    frame_count: c.ma_uint32,
) callconv(.c) void {
    _ = input; // playback only, no capture
    const state: *SineState = @ptrCast(@alignCast(device.?.*.pUserData));
    const out: [*]f32 = @ptrCast(@alignCast(output.?));
    var f: usize = 0;
    while (f < frame_count) : (f += 1) {
        const s = state.amplitude * @sin(state.phase);
        state.phase += state.increment;
        if (state.phase >= 2.0 * std.math.pi) state.phase -= 2.0 * std.math.pi;
        out[f * 2 + 0] = s; // left
        out[f * 2 + 1] = s; // right (same -> mono in a stereo stream)
    }
}

Two casts carry all the interop weight. output arrives as ?*anyopaque (C's void *), and we know it is really an array of f32 because we are about to configure the device for float output -- so @ptrCast(@alignCast(...)) to [*]f32 gives us a many-item pointer we can index. The alignment cast is not ceremony: indexing a misaligned float pointer is undefined behavior, and @alignCast inserts the debug-mode check that catches it. Wrapping the phase back into 0..2*pi keeps the float from growing without bound over a long run, which would slowly erode @sin's precision. Note the callback does zero of the forbidden things: no alloc, no lock, no I/O. It just does arithmetic and writes floats.

Wiring up the device

Now the setup code that runs on the main thread, once. We fill in a ma_device_config, hand miniaudio our callback and a pointer to our state through pUserData, initialize, and start. The pUserData field is the standard C answer to "how does a context-free callback reach my data" -- you stash a pointer, and the library hands it back to you on every call:

pub fn main() !void {
    const sample_rate: u32 = 48_000;
    var state = SineState{
        .increment = 2.0 * std.math.pi * 440.0 / @as(f32, @floatFromInt(sample_rate)),
    };

    var config = c.ma_device_config_init(c.ma_device_type_playback);
    config.playback.format = c.ma_format_f32;
    config.playback.channels = 2;
    config.sampleRate = sample_rate;
    config.dataCallback = dataCallback;
    config.pUserData = &state; // handed back to us on every callback

    var device: c.ma_device = undefined;
    try ok(c.ma_device_init(null, &config, &device), error.DeviceInitFailed);
    defer c.ma_device_uninit(&device);

    try ok(c.ma_device_start(&device), error.DeviceStartFailed);
    std.debug.print("playing 440 Hz for 2 seconds...\n", .{});
    std.Thread.sleep(2 * std.time.ns_per_s); // audio runs on its own thread
}

The shape here is worth internalizing: main sets up, starts, and then does nothing useful -- it just sleeps for two seconds while the audio thread, born inside ma_device_start, calls dataCallback hundreds of times behind our back. That inversion (your main thread idle, the device thread driving) is the pull model made concrete. The defer c.ma_device_uninit(&device) is the same RAII discipline we have leaned on since episode 7: whatever happens, the device is torn down on the way out. And &state outlives the device because it is a local in main, which stays on the stack for the whole run -- passing a pointer to a shorter-lived local would be a classic use-after-free across the C boundary, so mind the lifetimes.

Turning C error codes into a Zig error set

You saw try ok(...) above. C libraries report failure with an integer return code, not an error union, and if we let those ma_result integers leak through the whole program we lose everything Zig's error handling gives us (episode 4). So we build a one-line bridge that converts "not success" into a real Zig error, and from there up the rest of the code is idiomatic try:

/// Convert a miniaudio result code into a Zig error, or pass on success.
fn ok(result: c.ma_result, comptime err: anyerror) !void {
    if (result != c.MA_SUCCESS) return err;
}

This tiny helper is doing more than it looks. It lets the call site read try ok(c.ma_device_start(&device), error.DeviceStartFailed) -- the intent is right there, and a failure short-circuits main with a named error in stead of a bare integer nobody remembers the meaning of. For a bigger binding you would map each distinct ma_result onto its own error tag (a switch returning error.DeviceNotFound, error.InvalidArgs, and so on), so callers can react differently; the principle is the same either way. Translate at the boundary. The moment a C result crosses into Zig code, it should become a Zig error and never travel further as a raw code. That is how you keep an FFI-heavy program from rotting into C-with-Zig-syntax.

Feeding the callback from a background thread

A sine hard-coded in the callback is a nice first light, but real programs compute audio somewhere else -- a synth voice, a decoded song, a mixer -- and need to stream it to the device. The callback cannot compute that work itself (too much of it might allocate or block), so we split the program in two: a producer on a normal thread that generates samples, and the consumer callback that only copies them out. The safe channel between a producer and a consumer on different threads, with no lock, is the lock-free single-producer-single-consumer ring buffer we built in episode 113. The callback drains it; if it ever runs dry, it writes silence and moves on -- an underrun must degrade, never block:

const Player = struct {
    ring: *RingBuffer(f32), // the lock-free SPSC ring from episode 113
    channels: u8,

    fn callback(
        device: ?*c.ma_device,
        output: ?*anyopaque,
        input: ?*const anyopaque,
        frame_count: c.ma_uint32,
    ) callconv(.c) void {
        _ = input;
        const self: *Player = @ptrCast(@alignCast(device.?.*.pUserData));
        const out: [*]f32 = @ptrCast(@alignCast(output.?));
        const wanted = @as(usize, frame_count) * self.channels;
        var i: usize = 0;
        while (i < wanted) : (i += 1) {
            out[i] = self.ring.pop() orelse 0.0; // dry ring -> silence, never block
        }
    }
};

The producer runs wherever it likes and pushes samples in at its own pace, spinning briefly (or better, waiting on a condition) when the ring is momentarily full:

/// Generate `seconds` of a stereo tone and stream it into the player's ring.
fn feedTone(player: *Player, sample_rate: u32, freq: f32, seconds: f32) void {
    const frames: usize = @intFromFloat(@as(f32, @floatFromInt(sample_rate)) * seconds);
    var n: usize = 0;
    while (n < frames) {
        const t = @as(f32, @floatFromInt(n)) / @as(f32, @floatFromInt(sample_rate));
        const s = 0.2 * @sin(2.0 * std.math.pi * freq * t);
        // push one interleaved stereo frame; back off if the ring is full.
        if (player.ring.push(s) and player.ring.push(s)) {
            n += 1;
        } else {
            std.Thread.sleep(1 * std.time.ns_per_ms);
        }
    }
}

The asymmetry is the whole design: the consumer must never wait (it is on the real-time thread, and waiting there means a glitch), so it treats an empty ring as silence. The producer may wait (it is on an ordinary thread, and a millisecond nap costs nothing), so it backs off on a full ring. This is why the ring buffer's lock-free property matters so much for audio -- a mutex could park the audio thread while a slow producer holds the lock, and a parked audio thread is a dropout. Size the ring for a few callback blocks of headroom (latency versus safety is the tuning knob), fill it ahead of time, and the speaker stays fed. In a real synth the producer would render voices and mix them, which is exactly where the next couple of episodes are headed.

Testing audio output with no speaker

Here is the awkward part: the CI machine has no sound card, and even if it did, "listen to it" is not an assertion. The trick is the same one that saved us in the JPEG and PCM episodes -- do not test the device, test the logic. The callback is a thin C-convention shim around a pure fill function; so extract that pure function, and test it with an ordinary slice, no device in sight:

/// The pure part of the callback: fill an interleaved stereo block. No device.
fn renderSine(out: []f32, state: *SineState) void {
    var f: usize = 0;
    while (f * 2 + 1 < out.len) : (f += 1) {
        const s = state.amplitude * @sin(state.phase);
        state.phase += state.increment;
        if (state.phase >= 2.0 * std.math.pi) state.phase -= 2.0 * std.math.pi;
        out[f * 2 + 0] = s;
        out[f * 2 + 1] = s;
    }
}

test "render stays in range, keeps channels equal, and advances phase" {
    var state = SineState{ .increment = 2.0 * std.math.pi * 440.0 / 48_000.0 };
    var block: [512]f32 = undefined;
    renderSine(&block, &state);
    var f: usize = 0;
    while (f < 256) : (f += 1) {
        try std.testing.expect(block[f * 2] >= -0.2 and block[f * 2] <= 0.2);
        try std.testing.expectEqual(block[f * 2], block[f * 2 + 1]); // mono in stereo
    }
    try std.testing.expect(state.phase > 0.0); // clock moved forward
}

The real callback becomes almost nothing -- cast the pointers, then renderSine(out[0..wanted], state). That is a design lesson beyond audio: keep the untestable boundary layer as thin as you possibly can, and push all the real logic into pure functions behind it. The FFI shim, the pointer casts, the callconv(.c) -- none of that can be unit-tested without a device, so it should contain no decisions. Everything that could be wrong (the math, the range, the channel layout, the phase) lives in renderSine, which a test can drive directly and deterministically. miniaudio also ships a "null" backend that runs the whole callback machinery with no hardware, which is handy for an integration smoke-test, but the bread-and-butter coverage comes from testing the pure core.

Performance: the callback is sacred

Everything about the audio hot path comes down to one commandment: the callback must return before the device needs the next block, every time, with no exceptions. At 48 kHz with a 480-frame block you have about ten milliseconds, and ten milliseconds is an ocean for arithmetic but a puddle if you do something silly. The silly things, ranked by how often they bite:

  • Never allocate in the callback. A malloc (or a garbage-collector pause, in a managed language) can stall for far longer than your block budget. Size every buffer up front from the spec, as we did. This is where Zig's explicit, allocate-outside-the-hot-path model is a real edge -- there is no hidden allocation to ambush you.
  • Never take a lock the producer might hold. That is the entire reason we route samples through a lock-free ring in stead of a mutex-guarded queue.
  • Never touch the filesystem, the network, or std.debug.print. They can block for milliseconds, which is a guaranteed dropout. Log from another thread if you must.
  • Do the heavy DSP elsewhere. Batch gain and filtering over slices with @Vector (episode 19) on the producer side; the callback should be close to a @memcpy.

Notice these are almost all rules about what not to do. The audio thread is a place where the best code is the code that does the least. Measure-first still applies (episode 34): for a two-second tone none of this matters and the naive loop is fine, but for a live stream at 48 kHz the whole game is prepare early, hand over cheaply, and touch nothing slow on the audio thread.

The same job in C, Rust and Go

The callback model is universal -- every serious audio API on every platform is some flavor of "register a function, the device calls it." What differs is the ergonomics around it. In C, miniaudio is the code we are already binding, so the callback is the native shape and you carry the state pointer by hand:

// C with miniaudio: the callback is C-native, userData carries your state.
void data_callback(ma_device* dev, void* out, const void* in, ma_uint32 frames) {
    SineState* st = (SineState*)dev->pUserData;
    float* o = (float*)out;
    for (ma_uint32 f = 0; f < frames; f++) {
        float s = st->amplitude * sinf(st->phase);
        st->phase += st->increment;
        o[f * 2 + 0] = s;
        o[f * 2 + 1] = s;
    }
}

Rust reaches for cpal, which wraps the platform APIs and hands you a closure in stead of a raw function pointer -- so your state is captured by the closure and the borrow checker makes sure it lives long enough, which is a genuinely nicer story than a void* you have to keep alive yourself:

// Rust with cpal: a closure captures state; the compiler enforces the lifetime.
let mut phase = 0.0f32;
let stream = device.build_output_stream(
    &config,
    move |out: &mut [f32], _| {
        for frame in out.chunks_mut(2) {
            let s = 0.2 * phase.sin();
            phase += increment;
            frame[0] = s;
            frame[1] = s;
        }
    },
    err_fn, None,
)?;

Go does not ship audio in its standard library, so you pull a package like oto, which exposes a Player you write samples into -- more of a streaming API than a callback, with the runtime managing the device thread underneath. It is pleasant for offline or casual playback, but the garbage collector makes a hard real-time guarantee awkward, which is precisely the glitch risk we engineer around. Where does Zig sit? It has no native audio library, and it does not need one: the C interop of episodes 27 and 28 lets us drive the same battle-tested C libraries (miniaudio, PortAudio, RtAudio) at zero overhead, while keeping the parts that teach -- the synthesis, the ring-buffer handoff, the pure fill function -- in clean, explicit, allocator-honest Zig. We get C's ecosystem and Zig's clarity in the same binary, which is exactly the trade this whole two-episode arc was built to show off.

Exercises

  1. Frequency on the fly. Add an increment field the producer can change while playback runs (store it as an atomic per episode 30, or push "set frequency" events through a second ring), and sweep a tone from 220 Hz to 880 Hz over two seconds. Make sure you change the phase increment, not the phase itself, so the wave stays continuous and does not click at each change.

  2. Play a WAV through the device. Wire last episode's readWavChunked to the ring-buffer Player: load a 16-bit stereo WAV, convert each i16 to f32 with i16ToFloat, and stream the samples into the ring from a producer thread while the callback drains them. Handle a sample-rate mismatch by at least detecting it and printing a warning (real resampling comes later).

  3. An underrun counter. Give the callback an atomic counter it bumps every time the ring is dry and it has to emit silence for a frame. Print the count at the end of a run, then deliberately starve the producer (sleep it too long) and watch the number climb -- you have just built the simplest possible glitch detector, the thing every audio engine has in some form.

What we learned

  • A real audio device is a pull model: the sound card calls your callback on a strict clock, so the program inverts -- your main thread idles while the device thread drives, and being late means an audible glitch;
  • Talking to hardware is a job for C interop, not from-scratch code -- one @cImport of a single-header library like miniaudio plus a build.zig that compiles its implementation and links libc, exactly the boring recipe episodes 27, 28 and 15 promised;
  • A C callback must be callconv(.c), reaches your state through the pUserData pointer (mind its lifetime across the boundary), and turns C's void* output into a typed [*]f32 with @ptrCast(@alignCast(...));
  • C result codes should be translated at the boundary into a Zig error set, so the rest of the program stays idiomatic try in stead of bare integers;
  • Stream audio by splitting producer and consumer: the producer generates samples and pushes them through the lock-free ring buffer from episode 113, and the callback only drains -- an underrun degrades to silence and never blocks, because the audio thread must never wait;
  • Test the logic, not the device -- keep the untestable FFI shim razor-thin and push all the real work into a pure fill function -- and obey the hot-path commandments (no alloc, no lock, no I/O in the callback), where Zig's explicit memory model is a genuine advantage.

So the sound finally leaves the file and reaches the speaker in real time, computed on one thread and consumed on another, with the device politely asking for more every few milliseconds. But the thing we are feeding it is still a lonely sine tone. That is about to change: once you can shape a wave's frequency and amplitude over time, and stack more than one of them together, a bare oscillator turns into something you can actually play. The buffer stops being a static tone and starts becoming an instrument. Plenty still to build, and it gets a lot more fun from here.

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

scipio@scipio

Learn Zig Series (#170) - Audio Output via C Interop | Ecency