# 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](https://github.com/marlersoft/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](https://git.jcollie.dev/jeff/zig-pipewire), and the two have deliberately similar APIs. The API reference is generated from the doc comments and published at . ```zig 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 `linkSystemLibrary` calls: Zig links `ole32` and `avrt` from the `extern` declarations in the bindings. ## Installation ```sh zig fetch --save git+https://git.jcollie.dev/jeff/zig-wsapi.git ``` Then wire the module up in `build.zig`: ```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. ```zig 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. ```zig 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 ```zig 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 ```zig 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: - **`setDefaultSink` cannot work.** Windows exposes no public API for choosing the default endpoint; the Sound control panel uses `IPolicyConfig`, 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 with `Stream.Options.target` instead. - **`Stats.wake` is 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_cycles` is inferred**, not reported: there is no glitch counter. `underrun_frames` is 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](https://git.jcollie.dev/jeff/zig-wsapi): ```sh git clone https://git.jcollie.dev/jeff/zig-wsapi.git ``` It is mirrored to: - **Tangled**, at . - **Radicle**, as `rad:z2sCVUCXDvVqYFrE4UsDk8J9fTXje`: ```sh rad clone rad:z2sCVUCXDvVqYFrE4UsDk8J9fTXje ``` - **GitHub**, which exists for its `windows-latest` runner. The API reference, generated from the doc comments, is published at . ## Development ```sh 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: ```sh 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.reopens` counts it; - that the program appears in the Volume Mixer under the expected name and that its slider works. ## References cited - [About WASAPI](https://learn.microsoft.com/en-us/windows/win32/coreaudio/wasapi) and the Core Audio APIs documentation, for the interfaces and their ordering rules. - `mmdeviceapi.h`, `audioclient.h`, `mmreg.h` and `ksmedia.h`, for the constants and the `WAVEFORMATEXTENSIBLE` layout the golden tests assert. - [Low latency audio](https://learn.microsoft.com/en-us/windows/win32/coreaudio/low-latency-audio), for `IAudioClient3` and the shared-mode period. - [zigwin32](https://github.com/marlersoft/zigwin32), which is where every Win32 declaration here comes from. - [zig-pipewire](https://git.jcollie.dev/jeff/zig-pipewire), whose API this one mirrors. ## License MIT. See `LICENSES/MIT.txt`.