zig-wsapi #
Play and record audio on Windows, from Zig, without linking a C library.
It speaks WASAPI — the Windows Audio Session API — through COM directly, using zigwin32 for the bindings and nothing else. No miniaudio, no PortAudio, no libc. This is the Windows half of a pair; the Linux half is zig-pipewire, and the two have deliberately similar APIs.
The API reference is generated from the doc comments and published at https://jeff.jcollie.page/zig-wsapi/.
const stream = try wasapi.Stream.open(gpa, io, .{ .name = "my app", .channels = 2 });
defer stream.close(io);
_ = try stream.waitStreaming(io, five_seconds);
try stream.writeAll(io, interleaved_f32_samples, .none);
stream.drain(io, two_seconds);
What it does #
Stream |
Playback. Push frames with write/writeAll, or supply them from a Process callback once per graph cycle. Shared mode, float samples, IAudioClient3 low-latency periods where the hardware allows. |
Capture |
Recording, from a microphone or — with .source = .loopback — from whatever the machine is playing. |
Session |
The control plane: enumerate endpoints, read and set their volumes and mute, and list what each program is playing, which is what the Volume Mixer shows. |
| Follows the default | When the user changes their output device, or unplugs it, a stream moves itself to the new one and the queued audio plays on. |
Requirements #
- Zig 0.17
- Windows Vista or later.
IAudioClient3's low-latency path needs Windows 10 1703 and falls back gracefully without it. - Nothing else. No libc, and no
linkSystemLibrarycalls: Zig linksole32andavrtfrom theexterndeclarations in the bindings.
Installation #
zig fetch --save git+https://git.jcollie.dev/jeff/zig-wsapi.git
Then wire the module up in build.zig:
const wasapi = b.dependency("wasapi", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("wasapi", wasapi.module("wasapi"));
The Win32 bindings are a lazy dependency of this package, so they are fetched when you first build for Windows and never otherwise.
Usage #
Playing a tone #
The push API. A queue sits between your loop and the audio engine, which is what lets an irregular producer play cleanly.
const stream = try wasapi.Stream.open(gpa, io, .{
.name = "my app",
.channels = 2,
.rate = 48000,
});
defer stream.close(io);
_ = try stream.waitStreaming(io, timeout);
var block: [960 * 2]f32 = undefined;
var phase: f32 = 0;
const step = 2.0 * std.math.pi * 440.0 / @as(f32, @floatFromInt(stream.rate()));
while (playing) {
for (0..block.len / 2) |i| {
const sample = @sin(phase) * 0.2;
block[i * 2 + 0] = sample;
block[i * 2 + 1] = sample;
phase += step;
}
try stream.writeAll(io, &block, timeout);
}
// So the tail is heard rather than cut off.
stream.drain(io, timeout);
Supplying audio on demand #
The pull API. Lower latency, and the right shape for a synthesiser — at the cost of producing audio on the engine's schedule, inside a real-time deadline.
fn fill(ctx: *anyopaque, planes: []const []f32, frames: u32) void {
const synth: *Synth = @ptrCast(@alignCast(ctx));
for (0..frames) |i| {
const sample = synth.next();
for (planes) |plane| plane[i] = sample;
}
}
const stream = try wasapi.Stream.open(gpa, io, .{
.channels = 2,
.process = .{ .ctx = &synth, .func = &fill },
});
Recording #
const capture = try wasapi.Capture.open(gpa, io, .{ .source = .microphone });
defer capture.close(io);
var frames: [4800 * 2]f32 = undefined;
while (recording) {
const got = try capture.readAll(io, &frames, timeout);
try file.write(frames[0 .. got * capture.channels()]);
}
.source = .loopback records the output instead, which is how you capture system audio
without a virtual cable.
Listing and changing volumes #
var session: wasapi.Session = try .open(io, .{});
defer session.close(io);
var sinks = try session.sinks();
defer sinks.deinit();
while (try sinks.next()) |endpoint| {
var device = endpoint;
defer device.deinit();
var buf: [wasapi.Endpoint.max_name_len]u8 = undefined;
std.debug.print("{s}: {f}\n", .{
try device.label(&buf),
try session.volume(device),
});
}
Mapping from PipeWire #
If you know zig-pipewire, this is what moves and what does not. Windows has no
user-visible audio graph, and most of the differences follow from that.
| zig-pipewire | zig-wsapi | |
|---|---|---|
Stream.open / close |
same | io is threaded through, as in zig-hidapi |
write / writeAll / writable / queued |
same | |
Process callback, planes per channel |
same | one transpose more here: a WASAPI buffer is always interleaved |
pause / unpause / isActive |
same | |
rate / quantum / time / stats |
same | quantum is the engine period; see below |
graphChannels |
endpointChannels |
there is no graph to belong to |
Session sinks and sources |
Session.sinks / sources |
a live query, not a cached registry — so no roundTrip |
Session streams |
Session.appSessions |
one entry per process, as in the Volume Mixer |
setVolume / setMute |
same, per endpoint | plus AppSession.setLevel for one program's own |
Options.environ / runtime_dir / remote |
gone | there is no socket to find |
Options.autoconnect |
gone | WASAPI always connects |
createLink / linkNodes / destroy |
gone | no ports, no links, nothing to wire |
| metadata store | gone | no counterpart |
setDefaultSink |
present, always error.Unsupported |
see below |
| — | Capture with .loopback |
Windows does this more simply than PipeWire does |
| — | Options.follow_default, Stats.reopens |
PipeWire moves a node for you; here the library has to reopen |
Three things are worth reading the doc comments for rather than guessing at:
setDefaultSinkcannot work. Windows exposes no public API for choosing the default endpoint; the Sound control panel usesIPolicyConfig, which is undocumented, absent from the Windows metadata, and has changed shape between releases. The call exists so the gap is documented where you would look for it. Choose your own endpoint withStream.Options.targetinstead.Stats.wakeis not PipeWire's WAIT. PipeWire gets a timestamp from the driver saying when the node was marked ready. WASAPI has no such timestamp, so this is the deviation of the interval between wakeups from the period — a good jitter measure and not a scheduling latency.Stats.missed_cyclesis inferred, not reported: there is no glitch counter.underrun_framesis exact and is usually the number you want.
API overview #
wasapi.Stream #
open close write writeAll writable queued underruns pause unpause
isActive rate quantum endpointChannels endpointFormat getState time stats
resetStats waitStreaming drain
wasapi.Capture #
open close read readAll available overruns rate channels
capturedFormat quantum getState stats waitStreaming
wasapi.Session #
open close endpoints sinks sources default defaultSink defaultSource
byId volume setVolume setChannelVolumes setMute peak appSessions
setDefaultSink
Lower layers #
wasapi.com is exported for callers who need to reach past the three types above:
apartment handling, HRESULT mapping, endpoint enumeration, and the activation path. The
operating-system-independent half — Ring, channel, format, mix, time, stats,
wait — is exported too, and is importable on any host through src/portable.zig.
Errors #
Every error set is explicit and lives in src/errors.zig; none is inferred. A HRESULT
never reaches a caller: it is collapsed into one of the members of wasapi.Error and
written to the log on the way past, so the error says what happened and the log says which
number said so.
The members worth handling specifically are DeviceInvalidated (the endpoint went away —
usually handled for you, see follow_default), DeviceInUse (another program holds it
exclusively), AccessDenied (the microphone privacy setting, most often) and
ServiceNotRunning.
Where this lives #
The canonical repository is on Forgejo at git.jcollie.dev/jeff/zig-wsapi:
git clone https://git.jcollie.dev/jeff/zig-wsapi.git
It is mirrored to:
-
Tangled, at https://tangled.org/jcollie.dev/zig-wsapi.
-
Radicle, as
rad:z2sCVUCXDvVqYFrE4UsDk8J9fTXje:rad clone rad:z2sCVUCXDvVqYFrE4UsDk8J9fTXje -
GitHub, which exists for its
windows-latestrunner.
The API reference, generated from the doc comments, is published at https://jeff.jcollie.page/zig-wsapi/.
Development #
zig build # the examples into zig-out/bin
zig build test # the test suite
zig build check # compile for every supported target, without linking
zig build docs # API reference into zig-out/docs
zig build docs-serve # ...and serve it at http://127.0.0.1:8000/
zig build fuzz # the fuzz targets, once each
tools/wine-test.sh # the Windows suite under Wine, on x86-64 Linux
zig build run-devices # list endpoints, volumes and sessions
zig build run-tone -- 440 2
zig build run-callback -- 2
zig build run-record -- 5 voice.wav
zig build run-loopback -- 5 output.wav
zig build docs needs a Windows target, because the module it documents is
Windows-only -- on another host, zig build docs -Dtarget=x86_64-windows-gnu. And
--fuzz is not implemented for Windows in Zig 0.17, so the fuzz targets run once each
here and are actually fuzzed on the Linux runner, which is why they live in the portable
half.
and the lint trio:
reuse lint
typos
zig fmt --check --exclude zig-pkg .
What the tests cover, and what they cannot #
zig build test on any host runs the operating-system-independent suite: the ring
buffer under two-thread torture, the channel permutations against a table of real
dwChannelMask values, the WAVEFORMATEXTENSIBLE encoder against byte-exact golden
headers, every sample conversion round trip, the performance-counter arithmetic across ten
years of uptime, the statistics handoff under concurrent readers, and the wait loop.
zig build check type-checks every line of the COM half from any host, cross-compiled
for two Windows architectures — vtable signatures, GUID namespaces, cast shapes — and
compiles the portable half for Linux, which is what keeps it free of Win32.
On Windows the suite adds the COM tests, the apartment handling, and
tests/loopback.zig: it plays a 440 Hz tone, records it back through a loopback capture,
and asserts with a Goertzel filter that the energy came back at the right pitch. That one
test exercises the whole library at once.
Under Wine, on x86-64 Linux, tools/wine-test.sh runs that same Windows suite. It
gives Wine a private PulseAudio server whose only sink is a null sink, so nothing reaches
the speakers and the loopback test still has an endpoint to play to and record back from.
Wine's audio path is not Windows', but it is the one place the loopback test runs on every
push, since the Forgejo workflow runs it there in every optimize mode. The script uses a
throwaway Wine prefix and leaves ~/.wine alone. The dev shell carries the 64-bit build
of Wine it needs; the plain wine package is 32-bit only and rejects the test binaries
with "Bad EXE format".
A hosted Windows runner generally has no audio endpoint, so the playback and capture
tests report SkipZigTest there. What the GitHub Windows job actually buys is that every
symbol exists in real Windows DLLs, the structure layouts are right, and the apartment
handling is correct. The audible half needs a real machine, which means before a release:
- that a tone is clean and not clicking, and that a chord sounds the same through the pull API;
- that surround channels come out of the right speakers — the golden tables prove the permutation is self-consistent, not that it is correct;
- that changing the default output device mid-tone migrates without a stoppage, and that
Stats.reopenscounts it; - that the program appears in the Volume Mixer under the expected name and that its slider works.
References cited #
- About WASAPI and the Core Audio APIs documentation, for the interfaces and their ordering rules.
mmdeviceapi.h,audioclient.h,mmreg.handksmedia.h, for the constants and theWAVEFORMATEXTENSIBLElayout the golden tests assert.- Low latency audio,
for
IAudioClient3and the shared-mode period. - zigwin32, which is where every Win32 declaration here comes from.
- zig-pipewire, whose API this one mirrors.
License #
MIT. See LICENSES/MIT.txt.
- Zig 99%
- Nix <1%
- Shell <1%