Something went wrong. Try again.
Play and record audio on Windows from Zig through WASAPI and COM, without linking a C library.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! What is playing, where, and how loudly: the control plane.//!//! ```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} at {d:.0}%\n", .{//! try device.label(&buf),//! (try session.volume(device)).level * 100,//! });//! }//! ```//!//! ## How this differs from zig-pipewire's `Session`, and why//!//! PipeWire has a graph, so its `Session` is a *cache*: it subscribes to a registry,//! keeps a copy of every object, and `roundTrip` brings the copy up to date. Windows//! has no graph and no registry. What it has is two live queries -- enumerate the//! endpoints, enumerate the audio sessions on an endpoint -- so this type is a handle//! that runs them rather than a cache that mirrors them.//!//! That removes `roundTrip` (there is nothing to synchronise), `Object`,//! `ObjectType` and `Filter` (the query takes the filter), and the whole idea of an//! object id that stays valid (an `Endpoint` is a reference-counted COM object the//! caller holds). It also means this type allocates nothing at all.//!//! Three things PipeWire can do that Windows simply cannot, each of which would//! otherwise be a puzzling absence://!//! * **Ports and links.** There is no user-visible graph to wire up. Which//! endpoint a program plays to is chosen by the program (`Stream.Options.target`)//! or by Windows, and nothing rewires an existing stream.//! * **Setting the default endpoint.** See `setDefaultSink`.//! * **A metadata store.** Nothing corresponds to it.//!//! ## Two volumes, which are different things//!//! An **endpoint** volume is the device's own -- what the speaker icon in the system//! tray moves, shared by everything playing to that device. An **audio session**//! volume belongs to one process, and is what the per-application sliders in the//! Volume Mixer move. `volume` and `setVolume` take an endpoint; `AppSession.level`//! and `setLevel` take a session. Conflating them is how a program ends up turning//! the whole machine down when it meant to turn itself down.
const Session = @This();
const std = @import("std");
const errors = @import("errors.zig");const channel = @import("channel.zig");const Log = @import("log.zig").Log;
const com = @import("com/com.zig");const check = com.check;
const log = std.log.scoped(.wasapi);
const win32 = @import("win32").everything;
pub const Endpoint = com.Endpoint;pub const Flow = com.Flow;pub const Role = com.Role;pub const EndpointState = com.EndpointState;pub const Collection = com.enumerator.Collection;
/// How the session presents itself.pub const Options = struct { /// Unused by Windows, which has nothing to show a control-plane client's name in. /// Kept so that the shape matches zig-pipewire's `Session.Options` and so a /// program moving between them does not have to delete the field. name: []const u8 = "zig-wsapi", log: ?Log = null,};
apartment: com.Apartment,enumerator: com.Enumerator,options: Options,
/// Open a control-plane session on the calling thread.////// The thread is put in an apartment if it is not in one already -- and left alone if/// the host application already chose a different one, which for a graphical program/// it will have. See `com.Apartment`.////// Every interface this session holds is used on the thread that opened it. A/// `Session` is therefore **not** safe to share between threads; open one per thread/// that needs it, which costs an apartment reference and a COM object.pub fn open(io: std.Io, options: Options) errors.SessionError!Session { _ = io; var apartment: com.Apartment = try .enter(com.coinit_multithreaded); errdefer apartment.leave();
const enumerator: com.Enumerator = try .create(); return .{ .apartment = apartment, .enumerator = enumerator, .options = options };}
pub fn close(self: *Session, io: std.Io) void { _ = io; self.enumerator.deinit(); self.apartment.leave(); self.* = undefined;}
// --- finding endpoints ---
/// Every endpoint in one direction, including the ones switched off.////// The caller owns the collection and each endpoint it yields. `state_mask` is/// `com.enumerator.state_mask_all` for everything, or `DEVICE_STATE_ACTIVE` for just/// what can be played to.pub fn endpoints(self: *const Session, flow: Flow, state_mask: u32) errors.SessionError!Collection { return self.enumerator.collection(flow, state_mask);}
/// Every active output. The Windows counterpart of PipeWire's sinks.pub fn sinks(self: *const Session) errors.SessionError!Collection { return self.enumerator.collection(.render, win32.DEVICE_STATE_ACTIVE);}
/// Every active input.pub fn sources(self: *const Session) errors.SessionError!Collection { return self.enumerator.collection(.capture, win32.DEVICE_STATE_ACTIVE);}
/// The endpoint Windows would play this kind of audio to.pub fn default(self: *const Session, flow: Flow, role: Role) errors.SessionError!Endpoint { return self.enumerator.defaultEndpoint(flow, role);}
/// The default output, or null when the machine has none.////// Null rather than an error, because a machine with no sound card is a normal thing/// for a program to run on and not a failure it should report.pub fn defaultSink(self: *const Session) errors.SessionError!?Endpoint { return self.enumerator.defaultEndpoint(.render, .console) catch |err| switch (err) { error.DeviceNotFound => null, else => |e| e, };}
/// The default input, or null when the machine has none.pub fn defaultSource(self: *const Session) errors.SessionError!?Endpoint { return self.enumerator.defaultEndpoint(.capture, .console) catch |err| switch (err) { error.DeviceNotFound => null, else => |e| e, };}
/// One endpoint by the id `Endpoint.id` reported earlier.pub fn byId(self: *const Session, id: []const u8) errors.SessionError!Endpoint { return self.enumerator.byId(id);}
/// Change which endpoint Windows sends audio to. **Always fails.**////// Windows exposes no public API for this. The Sound control panel does it through/// `IPolicyConfig`, an undocumented interface that is not in the Windows metadata, has/// changed shape between Windows versions, and would have to be declared by hand from/// a reverse-engineered vtable. This library will not ship that: a program that got it/// wrong would corrupt the audio policy store rather than merely fail.////// This exists so the gap is documented where a reader will look for it, rather than/// being an unexplained absence. What a program can do instead is choose its own/// endpoint with `Stream.Options.target`, which needs no permission and affects/// nothing else on the machine.pub fn setDefaultSink(self: *const Session, id: []const u8) errors.SessionError!void { _ = self; _ = id; return error.Unsupported;}
// --- endpoint volume ---
/// How loud an endpoint is.pub const Volume = struct { /// The master level, 0 to 1, on the scale the volume slider uses -- which is /// perceptual rather than linear amplitude. This is the number to show a person. level: f32, /// Whether it is muted, which is independent of the level: unmuting restores /// whatever the level was. muted: bool, /// Per-channel levels, as linear amplitudes. `channels` of them are valid. channels: [channel.max_channels]f32 = @splat(0), channel_count: u16 = 0,
/// The valid part of `channels`. pub fn channelLevels(self: *const Volume) []const f32 { return self.channels[0..self.channel_count]; }
pub fn format(self: Volume, w: *std.Io.Writer) std.Io.Writer.Error!void { try w.print("{d:.0}%", .{self.level * 100}); if (self.muted) try w.writeAll(" (muted)"); }};
fn endpointVolume(endpoint: Endpoint) errors.SessionError!*win32.IAudioEndpointVolume { return endpoint.activate(win32.IAudioEndpointVolume, win32.IID_IAudioEndpointVolume);}
/// Read an endpoint's volume.pub fn volume(self: *const Session, endpoint: Endpoint) errors.SessionError!Volume { _ = self; const control = try endpointVolume(endpoint); defer com.release(control);
var level: f32 = 0; try check( "IAudioEndpointVolume::GetMasterVolumeLevelScalar", control.GetMasterVolumeLevelScalar(&level), );
var muted: win32.BOOL = 0; try check("IAudioEndpointVolume::GetMute", control.GetMute(&muted));
var count: u32 = 0; try check("IAudioEndpointVolume::GetChannelCount", control.GetChannelCount(&count));
var result: Volume = .{ .level = level, .muted = muted != 0 }; result.channel_count = @intCast(@min(count, channel.max_channels)); for (0..result.channel_count) |i| { var channel_level: f32 = 0; if (com.succeeded(control.GetChannelVolumeLevelScalar(@intCast(i), &channel_level))) { result.channels[i] = channel_level; } } return result;}
/// Set an endpoint's master volume, 0 to 1.////// This is the whole device: everything playing to it gets quieter. To change only/// this program's contribution, use `Stream.setVolume` or an `AppSession`.pub fn setVolume(self: *const Session, endpoint: Endpoint, level: f32) errors.SessionError!void { _ = self; const control = try endpointVolume(endpoint); defer com.release(control);
try check( "IAudioEndpointVolume::SetMasterVolumeLevelScalar", control.SetMasterVolumeLevelScalar(std.math.clamp(level, 0, 1), null), );}
/// Set each channel of an endpoint separately, as linear amplitudes, in the endpoint's/// own channel order.pub fn setChannelVolumes( self: *const Session, endpoint: Endpoint, levels: []const f32,) errors.SessionError!void { _ = self; const control = try endpointVolume(endpoint); defer com.release(control);
var count: u32 = 0; try check("IAudioEndpointVolume::GetChannelCount", control.GetChannelCount(&count)); if (levels.len != count) { log.warn("endpoint has {d} channels and {d} levels were given", .{ count, levels.len }); return error.Unexpected; }
for (levels, 0..) |level, i| { try check( "IAudioEndpointVolume::SetChannelVolumeLevelScalar", control.SetChannelVolumeLevelScalar( @intCast(i), std.math.clamp(level, 0, 1), null, ), ); }}
pub fn setMute(self: *const Session, endpoint: Endpoint, muted: bool) errors.SessionError!void { _ = self; const control = try endpointVolume(endpoint); defer com.release(control); try check("IAudioEndpointVolume::SetMute", control.SetMute(if (muted) 1 else 0, null));}
/// The loudest sample the endpoint has seen lately, 0 to 1.////// What a level meter shows. It is a peak over an unspecified recent window rather/// than over a period a caller chooses, which is all Windows offers -- so it is/// suitable for a meter and not for measurement.pub fn peak(self: *const Session, endpoint: Endpoint) errors.SessionError!f32 { _ = self; const meter = try endpoint.activate( win32.IAudioMeterInformation, win32.IID_IAudioMeterInformation, ); defer com.release(meter);
var value: f32 = 0; try check("IAudioMeterInformation::GetPeakValue", meter.GetPeakValue(&value)); return value;}
// --- per-application audio sessions ---
/// What one process is playing to an endpoint, and how loudly.////// The Windows counterpart of a PipeWire stream node: one entry in the Volume Mixer./// The caller owns it and must `deinit` it.pub const AppSession = struct { control: *win32.IAudioSessionControl2, volume: *win32.ISimpleAudioVolume,
/// Enough for any session display name. Names come from executables and from /// applications' own strings, so this is a generous guess rather than a limit /// Windows documents. pub const max_name_len = 512;
pub fn deinit(self: *AppSession) void { com.release(self.volume); com.release(self.control); self.* = undefined; }
/// The process this audio belongs to, or zero for the system sounds session. pub fn processId(self: AppSession) errors.SessionError!u32 { var pid: u32 = 0; try check("IAudioSessionControl2::GetProcessId", self.control.GetProcessId(&pid)); return pid; }
/// Whether this is Windows' own notification-sound session rather than an /// application's. /// /// Worth telling apart: it has no process to name, and a mixer that lists it as a /// nameless entry looks broken. pub fn isSystemSounds(self: AppSession) bool { // `S_OK` for yes and `S_FALSE` for no -- both successes, which is why this // cannot go through `check`. return com.eql(self.control.IsSystemSoundsSession(), com.hresult.s_ok); }
/// The name the application set for itself, if it set one. /// /// Very often empty: most programs never call `SetDisplayName`, and the Volume /// Mixer shows their executable's name instead -- which it gets from the process, /// not from here. A caller wanting a name to show should fall back to looking up /// `processId`. pub fn displayName(self: AppSession, buf: []u8) errors.SessionError!?[]const u8 { var wide: ?[*:0]u16 = null; try check( "IAudioSessionControl2::GetDisplayName", self.control.IAudioSessionControl.GetDisplayName(&wide), ); const ptr = wide orelse return null; defer win32.CoTaskMemFree(@ptrCast(ptr)); const name = try com.utf8FromWide(buf, ptr); return if (name.len == 0) null else name; }
/// The identifier that stays the same across runs of the same application, for a /// program that remembers a per-application setting. pub fn identifier(self: AppSession, buf: []u8) errors.SessionError![]const u8 { var wide: ?[*:0]u16 = null; try check( "IAudioSessionControl2::GetSessionIdentifier", self.control.GetSessionIdentifier(&wide), ); const ptr = wide orelse return error.Unexpected; defer win32.CoTaskMemFree(@ptrCast(ptr)); return com.utf8FromWide(buf, ptr); }
/// Whether this session is making sound right now. pub fn state(self: AppSession) errors.SessionError!State { var value: win32.AudioSessionState = .Inactive; try check( "IAudioSessionControl::GetState", self.control.IAudioSessionControl.GetState(&value), ); return switch (value) { .Active => .active, .Inactive => .inactive, .Expired => .expired, }; }
pub const State = enum { /// Playing audio now. active, /// Open but silent: the program has a stream and is not feeding it. inactive, /// The program has gone. The entry lingers briefly so a mixer does not flicker. expired, };
/// This session's own volume, 0 to 1: the per-application slider. pub fn level(self: AppSession) errors.SessionError!f32 { var value: f32 = 0; try check("ISimpleAudioVolume::GetMasterVolume", self.volume.GetMasterVolume(&value)); return value; }
pub fn setLevel(self: AppSession, value: f32) errors.SessionError!void { try check("ISimpleAudioVolume::SetMasterVolume", self.volume.SetMasterVolume( std.math.clamp(value, 0, 1), null, )); }
pub fn muted(self: AppSession) errors.SessionError!bool { var value: win32.BOOL = 0; try check("ISimpleAudioVolume::GetMute", self.volume.GetMute(&value)); return value != 0; }
pub fn setMuted(self: AppSession, value: bool) errors.SessionError!void { try check("ISimpleAudioVolume::SetMute", self.volume.SetMute(if (value) 1 else 0, null)); }};
/// The audio sessions on one endpoint.pub const AppSessions = struct { manager: *win32.IAudioSessionManager2, list: *win32.IAudioSessionEnumerator, count: i32, index: i32 = 0,
pub fn deinit(self: *AppSessions) void { com.release(self.list); com.release(self.manager); self.* = undefined; }
/// The next session, or null at the end. The caller owns what comes back. pub fn next(self: *AppSessions) errors.SessionError!?AppSession { while (self.index < self.count) { const at = self.index; self.index += 1;
var control1: ?*win32.IAudioSessionControl = null; try check("IAudioSessionEnumerator::GetSession", self.list.GetSession(at, &control1)); const base = control1 orelse continue;
// Everything worth knowing about a session -- its process, its // identifier, whether it is the system sounds -- is on the second // version of the interface, so a session that has only the first is one // this library cannot describe. const control = com.queryInterface( win32.IAudioSessionControl2, base, win32.IID_IAudioSessionControl2, ) catch { com.release(base); continue; }; com.release(base);
const simple = com.queryInterface( win32.ISimpleAudioVolume, control, win32.IID_ISimpleAudioVolume, ) catch { com.release(control); continue; };
return .{ .control = control, .volume = simple }; } return null; }};
/// Every application playing to an endpoint: what the Volume Mixer lists.pub fn appSessions(self: *const Session, endpoint: Endpoint) errors.SessionError!AppSessions { _ = self; const manager = try endpoint.activate( win32.IAudioSessionManager2, win32.IID_IAudioSessionManager2, ); errdefer com.release(manager);
var list: ?*win32.IAudioSessionEnumerator = null; try check( "IAudioSessionManager2::GetSessionEnumerator", manager.GetSessionEnumerator(&list), ); const sessions = list orelse return error.Unexpected; errdefer com.release(sessions);
var count: i32 = 0; try check("IAudioSessionEnumerator::GetCount", sessions.GetCount(&count));
return .{ .manager = manager, .list = sessions, .count = count };}
test "setting the default endpoint reports that Windows will not allow it" { // Asserted rather than merely documented, because the temptation to "fix" this by // declaring `IPolicyConfig` by hand will recur -- and the reason not to is in the // doc comment above. var session: Session = undefined; try std.testing.expectError( error.Unsupported, session.setDefaultSink("{0.0.0.00000000}.{whatever}"), );}
test "a volume prints as a percentage a person can read" { var buf: [32]u8 = undefined; try std.testing.expectEqualStrings( "50%", try std.fmt.bufPrint(&buf, "{f}", .{Volume{ .level = 0.5, .muted = false }}), ); // Muting is independent of the level, so both are shown: a muted endpoint at // 80% is a different thing from one turned down to zero. try std.testing.expectEqualStrings( "80% (muted)", try std.fmt.bufPrint(&buf, "{f}", .{Volume{ .level = 0.8, .muted = true }}), );}
test "only the reported channels of a volume are valid" { // The array is fixed at `max_channels`; reading past `channel_count` would report // zeroes as though the endpoint had silent channels. var v: Volume = .{ .level = 1, .muted = false, .channel_count = 2 }; v.channels[0] = 0.5; v.channels[1] = 0.25; try std.testing.expectEqualSlices(f32, &.{ 0.5, 0.25 }, v.channelLevels());}
test "a session opens on this thread and closes cleanly" { // Exercises the apartment handling against whatever apartment the test runner's // thread is already in, which is the case a library actually meets. var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io();
var session: Session = try .open(io, .{}); defer session.close(io);
// A machine with no sound card is a valid machine, so null is a pass. if (try session.defaultSink()) |endpoint| { var device = endpoint; defer device.deinit(); try std.testing.expect((try device.state()).usable()); }}
test "enumerating endpoints yields as many as the collection claims" { var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io();
var session: Session = try .open(io, .{}); defer session.close(io);
var outputs = try session.sinks(); defer outputs.deinit();
var seen: u32 = 0; while (try outputs.next()) |endpoint| { var device = endpoint; defer device.deinit(); // Every endpoint the enumerator lists has an id, which is what makes it // reopenable later. var buf: [Endpoint.max_id_len]u8 = undefined; try std.testing.expect((try device.id(&buf)).len > 0); seen += 1; } try std.testing.expectEqual(outputs.count, seen);}
test { std.testing.refAllDecls(@This());}