Learn Zig Series (#170) - Audio Output via C Interop
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
@cImportinterop from episodes 27 and 28, and wire it up inbuild.zig; - How to hand your own Zig state to a C callback safely through the
pUserDatapointer, and why the callback signature has to becallconv(.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):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools
- Learn Zig Series (#115) - Slab Allocators
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Zig Series (#117) - Binary Search Variations
- Learn Zig Series (#118) - Graph Representation
- Learn Zig Series (#119) - BFS and DFS
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Zig Series (#121) - Topological Sort
- Learn Zig Series (#122) - Union-Find
- Learn Zig Series (#123) - LRU Cache
- Learn Zig Series (#124) - Consistent Hashing
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Zig Series (#129) - Mini Project: Database Engine - B-Tree Index
- Learn Zig Series (#130) - Mini Project: Database Engine - SQL Parser
- Learn Zig Series (#131) - Lexing a Simple Language
- Learn Zig Series (#132) - Recursive Descent Parsing
- Learn Zig Series (#133) - AST Design and Traversal
- Learn Zig Series (#134) - Type Checking
- Learn Zig Series (#135) - Bytecode Design
- Learn Zig Series (#136) - Stack-Based Virtual Machine
- Learn Zig Series (#137) - Closures and Upvalues
- Learn Zig Series (#138) - Garbage Collection: Mark and Sweep
- Learn Zig Series (#139) - Garbage Collection: Generational
- Learn Zig Series (#140) - JIT Compilation Basics
- Learn Zig Series (#141) - Regex: Thompson NFA
- Learn Zig Series (#142) - Regex: NFA to DFA
- Learn Zig Series (#143) - Regex: Matching Engine
- Learn Zig Series (#144) - Code Generation: AST to Machine Code
- Learn Zig Series (#145) - Register Allocation
- Learn Zig Series (#146) - Mini Project: Calculator - Lexer/Parser
- Learn Zig Series (#147) - Mini Project: Calculator - Interpreter
- Learn Zig Series (#148) - Mini Project: Calculator - Bytecode Compiler
- Learn Zig Series (#149) - Mini Project: Calculator - VM with Debugger
- Learn Zig Series (#150) - Mini Project: Lisp - Reader
- Learn Zig Series (#151) - Mini Project: Lisp - Evaluator
- Learn Zig Series (#152) - Mini Project: Lisp - Special Forms and Macros
- Learn Zig Series (#153) - Mini Project: Lisp - Standard Library
- Learn Zig Series (#154) - Mini Project: Regex Engine - NFA
- Learn Zig Series (#155) - Mini Project: Regex Engine - Matching
- Learn Zig Series (#156) - Framebuffer Basics
- Learn Zig Series (#157) - Line Drawing: Bresenham
- Learn Zig Series (#158) - Circle and Ellipse Rasterization
- Learn Zig Series (#159) - Polygon Filling: Scanline
- Learn Zig Series (#160) - 2D Transform Matrices
- Learn Zig Series (#161) - Double Buffering and Vsync
- Learn Zig Series (#162) - Sprite Rendering and Tile Maps
- Learn Zig Series (#163) - Bitmap Font Rendering
- Learn Zig Series (#164) - TrueType Parsing
- Learn Zig Series (#165) - Color Spaces: RGB, HSV, sRGB
- Learn Zig Series (#166) - Alpha Blending and Compositing
- Learn Zig Series (#167) - PNG Decoder in Zig
- Learn Zig Series (#168) - JPEG Decoder Basics
- Learn Zig Series (#169) - Audio Fundamentals: PCM and Buffers
- Learn Zig Series (#170) - Audio Output via C Interop (this post)
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
Frequency on the fly. Add an
incrementfield the producer can change while playback runs (store it as anatomicper 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.Play a WAV through the device. Wire last episode's
readWavChunkedto the ring-bufferPlayer: load a 16-bit stereo WAV, convert eachi16tof32withi16ToFloat, 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).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
@cImportof a single-header library like miniaudio plus abuild.zigthat 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 thepUserDatapointer (mind its lifetime across the boundary), and turns C'svoid*output into a typed[*]f32with@ptrCast(@alignCast(...)); - C result codes should be translated at the boundary into a Zig error set, so the rest of the program stays idiomatic
tryin 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! ;-)