No description
  • Zig 98.7%
  • Nix 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 0d6b594400
All checks were successful
test / reuse (push) Successful in 2m22s
Build the examples in CI as well as testing
zig build test compiles the library module and the fuzz targets and
nothing else, so an example that stopped compiling would have gone
unnoticed until someone ran it. The default step builds both of them.

Run through nix develop here before committing, as with the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0135fq2ZjgV7Eicd4XBdyvfp
2026-09-04 21:34:00 -05:00
.forgejo/workflows Build the examples in CI as well as testing 2026-09-04 21:34:00 -05:00
examples Add a Forgejo workflow 2026-09-04 21:32:21 -05:00
LICENSES Add a pure-Zig PipeWire playback client 2026-09-04 17:52:48 -05:00
src Measure what the library costs 2026-09-04 20:27:00 -05:00
tests Fuzz the parsers, and fix a crash they found 2026-09-04 20:03:12 -05:00
tools Add a Forgejo workflow 2026-09-04 21:32:21 -05:00
.gitignore Ignore zig-pkg, where zig fetch unpacks a dependency 2026-09-04 20:12:38 -05:00
build.zig Measure what the library costs 2026-09-04 20:27:00 -05:00
build.zig.zon Fuzz the parsers, and fix a crash they found 2026-09-04 20:03:12 -05:00
flake.lock Add a pure-Zig PipeWire playback client 2026-09-04 17:52:48 -05:00
flake.nix Fuzz the parsers, and fix a crash they found 2026-09-04 20:03:12 -05:00
README.md Build the examples in CI as well as testing 2026-09-04 21:34:00 -05:00
REUSE.toml Add a pure-Zig PipeWire playback client 2026-09-04 17:52:48 -05:00

zig-pipewire

A PipeWire client library in Zig for sending audio to the graph for live playback.

It speaks PipeWire's native wire protocol directly over the daemon's Unix socket: no libpipewire, no libc, no C at all. The only dependency is a running PipeWire daemon.

const pw = @import("pipewire");

const stream = try pw.Stream.open(gpa, .{
    .name = "my app",
    .channels = 2,
    .rate = 48000,
    .environ = init.minimal.environ,
});
defer stream.close();

_ = stream.waitStreaming(5000);
try stream.writeAll(interleaved_f32_samples);
stream.drain(2000);

Requires Zig 0.16 and Linux.

What it does

The library creates a client-node in the daemon's graph with one mono output port per channel, and lets the session manager route it like any other audio stream — it appears in wpctl status, pavucontrol and pw-top, and can be moved between sinks and have its volume changed from the usual tools.

Audio is handed over as 32-bit float. There are two ways to supply it:

  • Push. write and writeAll copy interleaved frames into a lock-free ring that the real-time thread drains. Call them from wherever is convenient.
  • Pull. Set Options.process and PipeWire calls back once per graph cycle with one plane per channel to fill in place. The callback runs on the real-time thread, so it must not allocate, lock or block.

What it does not do

Playback only — there is no capture side, no filter or duplex node, and no device or session management.

Format conversion is left to the graph. The stream produces planar f32 at the graph's own rate, which is what PipeWire's sinks consume; ask for the rate you want with Options.rate and read back what you got with Stream.rate.

Channel layout is negotiated rather than dictated: the session manager configures the node for the layout the sink is running, which may not be the one you asked for. Stream.write still takes the channel count you asked for and maps it onto the graph's:

  • a graph channel whose position you also supply is copied;
  • otherwise, if you supply a single channel, it feeds every graph channel;
  • otherwise, if the graph wants a single channel, it gets the average of yours;
  • otherwise that graph channel is silent, and yours at that position is dropped.

So mono plays through a stereo sink, stereo folds down to a mono sink, and 5.1 into a stereo sink keeps its front pair and loses the rest. Stream.graphChannels reports what the graph settled on. Anything more careful than the above is yours to do before handing samples over.

Examples

zig build run-tone  -- [seconds] [hz] [channels]   # push API
zig build run-chord -- [seconds]                   # pull API

Using it

zig fetch --save git+https://git.ocjtech.us/jeff/zig-pipewire.git#main
const pipewire = b.dependency("pipewire", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("pipewire", pipewire.module("pipewire"));

#main follows the branch; name a tag or a commit there instead to pin to one. The repository is at https://git.ocjtech.us/jeff/zig-pipewire.

Layout

file what it holds
src/stream.zig the playback API, negotiation and the real-time cycle
src/client_node.zig encoding and decoding for the client-node interface
src/core.zig the Core object, object ids, the memory pool
src/connection.zig message framing and descriptor passing
src/pod.zig SPA POD serialization
src/spa.zig SPA constants and shared-memory layouts
src/ring.zig the lock-free ring behind write
src/sys.zig the Linux syscalls the library makes
tools/bench.zig the benchmarks
tests/fuzz.zig the fuzz targets

Stream is the whole of the playback API; the lower layers are exported for callers who need to reach past it.

Tests

zig build test

The unit tests cover POD encoding and decoding, message framing over a socketpair (descriptor passing included), and the ring. They need no daemon. The layouts of the structures shared with the daemon are checked at compile time against the sizes PipeWire hard-codes.

Benchmarks

zig build bench                    # both halves
zig build bench -- micro
zig build bench -- live --seconds 30 --channels 6

An audio graph has a deadline rather than a throughput target: PipeWire wakes the node once per quantum and everything downstream waits on it, so the question is whether a cycle finishes in time every time. live plays a tone and reports what the cycles cost, taken from the two timestamps the node writes into the activation record it shares with the daemon — the same two pw-top reads as WAIT and BUSY, so the two can be checked against each other. micro times the routines a cycle is made of, with no daemon involved.

On a Ryzen 7 5800X, stereo at 48 kHz with a quantum of 1024 (a period of 21.3 ms), over 1876 cycles:

min mean max worst as % of period
wake (WAIT) 5.9 us 9.6 us 55.4 us 0.26%
process (BUSY) 2.2 us 3.2 us 25.8 us 0.12%

with no missed cycles and no underruns. Six channels routed down to a stereo sink roughly doubles the process time, to a mean of 6.4 us. pw-top sampling the same run agreed on both means to the figure it prints; its maxima are lower because it snapshots once a second while Stream.stats sees every cycle, and for a deadline the worst cycle is the one that matters.

The hot paths, per frame:

1 ch 2 ch 6 ch
Ring.write (producer thread) 0.05 ns 0.10 ns 0.45 ns
Ring.readPlanar (graph cycle) 1.21 ns 1.38 ns 2.79 ns

The write is a pair of memcpys and the read de-interleaves a sample at a time, which is where the twenty-fold gap comes from. It has not been worth closing: a 1024-frame quantum costs about 1.4 us to de-interleave against a 21.3 ms deadline.

For scale, pw-cat playing a stereo file at the same rate and quantum measured 6.5 us mean and 10.3 us peak BUSY against this library's 3.2 and 5.0 over the same number of pw-top samples. That is not a like-for-like comparison — pw-cat decodes S16LE and converts it through the adapter, where this library hands the graph planar f32 directly — so read it as the cost of each doing its own whole job, not as the same work done twice.

Both numbers are from one desktop with other things running on it. Measure your own.

Fuzzing

Every byte this library parses arrives over a socket from another process, and a PipeWire client trusts the daemon a long way: it maps memory the daemon names and turns integers the daemon sends into pointers into that memory. So the parsers are fuzzed as properties rather than examples — tests/fuzz.zig says what has to hold for every input there is, and its header explains each one.

zig build test                              # the checked-in corpus
zig build fuzz-run -- --seconds 60          # the loop in tools/fuzz.zig
zig build fuzz-run -- --target pod --seconds 300
zig build fuzz --fuzz                       # Zig's own fuzzer

zig build fuzz --fuzz needs the devshell's Zig, which patches one line of the 0.16.0 standard library; without it no project with a fuzz test in it can build a test executable at all. Even with the patch that release populates no table of program counters, so its fuzzer runs without coverage feedback — which is why tools/fuzz.zig exists. Both find things; neither is guided.

A failing input is written to fuzz-findings/ and can be run again with zig build fuzz-run -- --input <file>. A shape worth keeping belongs in the corpus at the bottom of tests/fuzz.zig, where zig build test will run it every time.

This turned up one real bug: spa.Direction was an exhaustive two-valued enum that five event decoders cast a daemon-supplied u32 into, so a malformed message crashed the client. Every enum decoded from the wire is now non-exhaustive. Each target has also been checked against a deliberately broken copy of the code it watches, so that a run which finds nothing means something; the header of tests/fuzz.zig lists what was broken and what caught it.

Continuous integration

.forgejo/workflows/test.yml runs on every push: REUSE compliance, zig fmt --check, a build of the examples, and the test suite, each through nix develop so the toolchain is the one this flake pins. The build step is there because zig build test compiles only the library and the fuzz targets, so a broken example would otherwise go unnoticed.

Licence

MIT.