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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Being told when the audio devices change, which means implementing a COM interface//! rather than calling one.//!//! ```zig//! var client = try NotificationClient.create(gpa, .{ .flags = &my_flags, .event = my_event });//! try client.register(enumerator);//! defer client.unregister(enumerator); // releases our reference too//! ```//!//! ## What a callback is allowed to do, which is almost nothing//!//! These methods are called by MMDevAPI on *its* worker thread, and the rules are//! strict enough to be worth stating as rules://!//! * **Never call back into `IMMDeviceEnumerator`.** It is documented as a deadlock://! the enumerator holds a lock while it notifies, and asking it anything from inside//! the notification waits for a lock the calling thread already owns.//! * **Never allocate.** Not because it is forbidden, but because a notification can//! arrive while the render thread is mid-cycle and an allocator lock shared with it//! would turn a device change into a dropped buffer.//! * **Never block.** A slow handler delays every other client's notifications.//!//! So each handler does exactly two things: set a bit, and signal an event. Everything//! that needs doing happens later -- on the render thread, which reopens, or on the//! caller's thread, which re-enumerates. The bit is the whole message.//!//! ## Why this is heap-allocated rather than embedded in the owner//!//! COM reference counting means the object is freed by its last `Release`, and there is//! no documented guarantee about when MMDevAPI drops its reference relative to//! `UnregisterEndpointNotificationCallback` returning. Embedding this in a `Stream` and//! letting the stream's storage die would be correct only if that guarantee existed.//!//! So: one small allocation per client, `Release` frees at zero, and two belts on top --//! `alive`, cleared before unregistering and checked at the top of every handler, and//! `in_flight`, which the owner drains before dropping its own reference. The drain//! exists precisely because the synchronisation is undocumented; if it were documented,//! this would be four fewer fields.
const std = @import("std");
const Allocator = std.mem.Allocator;
const errors = @import("../errors.zig");const com = @import("com.zig");const check = com.check;const enumerator_mod = @import("enumerator.zig");const Flow = enumerator_mod.Flow;
const log = std.log.scoped(.wasapi);
const win32 = @import("win32").everything;const Guid = win32.Guid;const HRESULT = win32.HRESULT;
/// What happened, as a set of bits.////// A bit set rather than a queue of events, because that is all a notification thread can/// safely produce and all either consumer needs: the render thread wants to know *that*/// the default changed, not the sequence of changes, and a listing wants to know *that*/// something moved so it can look again.pub const Flags = packed struct(u32) { /// The default render endpoint changed. What `Stream.Options.follow_default` acts on. default_render_changed: bool = false, /// The default capture endpoint changed. default_capture_changed: bool = false, /// An endpoint appeared. device_added: bool = false, /// An endpoint went away. device_removed: bool = false, /// An endpoint was enabled, disabled, plugged in or unplugged. device_state_changed: bool = false, /// A property changed -- usually a rename, occasionally a format change. property_changed: bool = false, _unused: u26 = 0,
pub fn any(self: Flags) bool { return @as(u32, @bitCast(self)) != 0; }};
/// Somewhere for a notification to land.////// Both members are optional so that the two consumers can each take what they need: a/// `Stream` wants the event, to break its render thread out of its wait; a listing wants/// only the bits.pub const Sink = struct { /// Bits are OR-ed into this. Read and cleared with an atomic exchange. flags: ?*std.atomic.Value(u32) = null, /// Signalled after the bits are set, so a thread waiting on it looks again. The one /// Win32 call a notification handler is allowed to make. event: ?com.Event = null,
fn deliver(self: Sink, bits: Flags) void { if (self.flags) |cell| _ = cell.fetchOr(@bitCast(bits), .release); if (self.event) |event| event.set(); }};
/// An `IMMNotificationClient` implemented in Zig.pub const NotificationClient = struct { /// The pointer MMDevAPI holds, and the first field so that `@fieldParentPtr` from it /// is a no-op -- which is worth having when it is reached from a callback on a thread /// this library does not own. interface: win32.IMMNotificationClient, /// COM's count, not ours: MMDevAPI adds a reference when registering. refs: std.atomic.Value(u32), /// Cleared before unregistering, so a callback already in flight does nothing. alive: std.atomic.Value(bool), /// Callbacks currently executing, drained by `unregister`. in_flight: std.atomic.Value(u32), sink: Sink, gpa: Allocator,
const vtable: win32.IMMNotificationClient.VTable = .{ .base = .{ .QueryInterface = queryInterface, .AddRef = addRef, .Release = release, }, .OnDeviceStateChanged = onDeviceStateChanged, .OnDeviceAdded = onDeviceAdded, .OnDeviceRemoved = onDeviceRemoved, .OnDefaultDeviceChanged = onDefaultDeviceChanged, .OnPropertyValueChanged = onPropertyValueChanged, };
/// `IID_IUnknown`, which `zigwin32` does not name. const iid_iunknown = Guid.initString("00000000-0000-0000-c000-000000000046");
pub fn create(gpa: Allocator, sink: Sink) Allocator.Error!*NotificationClient { const self = try gpa.create(NotificationClient); self.* = .{ // An `IMMNotificationClient` is one pointer wide -- a union of the vtable // pointer and the `IUnknown` view of it -- so this is the whole object as // far as COM is concerned. .interface = .{ .vtable = &vtable }, .refs = .init(1), .alive = .init(true), .in_flight = .init(0), .sink = sink, .gpa = gpa, }; return self; }
/// Start receiving notifications. pub fn register(self: *NotificationClient, enumerator: com.Enumerator) errors.Error!void { try check( "IMMDeviceEnumerator::RegisterEndpointNotificationCallback", enumerator.ptr.RegisterEndpointNotificationCallback(&self.interface), ); }
/// Stop receiving notifications, wait for any in flight to finish, and drop our /// reference. /// /// The object may outlive this call if MMDevAPI still holds a reference; it frees /// itself when the last one goes. Either way the sink is never touched again, because /// `alive` is cleared first. pub fn unregister(self: *NotificationClient, enumerator: com.Enumerator) void { // Before unregistering, so that a callback which has already started but not yet // read this does nothing. self.alive.store(false, .release);
const hr = enumerator.ptr.UnregisterEndpointNotificationCallback(&self.interface); if (com.failed(hr)) { log.warn("UnregisterEndpointNotificationCallback failed: {f}", .{hr}); }
// Drain. Bounded, because a handler here does two atomic operations and cannot // block -- so this either returns immediately or after a handler finishes the // few instructions it was in the middle of. var spins: u32 = 0; while (self.in_flight.load(.acquire) != 0 and spins < 100_000) : (spins += 1) { std.atomic.spinLoopHint(); } if (self.in_flight.load(.acquire) != 0) { // Should not happen, and leaking one small allocation is a far better // outcome than freeing memory a foreign thread is still reading. log.warn("a device notification is still running; leaking the client", .{}); return; }
_ = self.interface.IUnknown.Release(); }
/// Recover the object from the interface pointer COM hands back. fn from(iface: *const win32.IMMNotificationClient) *NotificationClient { // `@constCast` because every method in these bindings takes `self: *const T`, // while the object behind it is ours and mutable. return @constCast(@fieldParentPtr("interface", iface)); }
/// Guard every handler: returns null when the owner has finished with us. fn enter(iface: *const win32.IMMNotificationClient) ?*NotificationClient { const self = from(iface); if (!self.alive.load(.acquire)) return null; _ = self.in_flight.fetchAdd(1, .acquire); // Checked again after claiming, so a client that was retired between the two does // not deliver. if (!self.alive.load(.acquire)) { _ = self.in_flight.fetchSub(1, .release); return null; } return self; }
fn leave(self: *NotificationClient) void { _ = self.in_flight.fetchSub(1, .release); }
/// Recover the object from the `IUnknown` view of it. /// /// The three `IUnknown` slots are typed against `IUnknown` rather than against the /// derived interface, so they arrive with a differently-typed pointer to the same /// address -- `IMMNotificationClient` being a union of pointer-sized views of one /// vtable pointer, exactly so that this cast is free. fn fromUnknown(iface: *const win32.IUnknown) *NotificationClient { const derived: *const win32.IMMNotificationClient = @ptrCast(iface); return from(derived); }
fn queryInterface( iface: *const win32.IUnknown, riid: *const Guid, ppv: **anyopaque, ) callconv(.winapi) HRESULT { const self = fromUnknown(iface);
if (com.guidEql(riid, win32.IID_IMMNotificationClient) or com.guidEql(riid, &iid_iunknown)) { _ = self.refs.fetchAdd(1, .monotonic); ppv.* = @ptrCast(&self.interface); return com.hresult.s_ok; }
nullOut(ppv); return win32.E_NOINTERFACE; }
/// Write null through the out-parameter. /// /// COM requires `*ppvObject == NULL` when `QueryInterface` fails, and `zigwin32` /// types the parameter as a non-optional `**anyopaque`, which cannot express that. /// A caller that checks the pointer instead of the `HRESULT` -- and they exist -- /// would otherwise read whatever was there. fn nullOut(ppv: **anyopaque) void { @as(*?*anyopaque, @ptrCast(ppv)).* = null; }
fn addRef(iface: *const win32.IUnknown) callconv(.winapi) u32 { return fromUnknown(iface).refs.fetchAdd(1, .monotonic) + 1; }
fn release(iface: *const win32.IUnknown) callconv(.winapi) u32 { const self = fromUnknown(iface); const previous = self.refs.fetchSub(1, .release); if (previous == 1) { // Acquire, so that everything every other holder did before its own release // is visible before the memory is reused. _ = self.refs.load(.acquire); const gpa = self.gpa; self.* = undefined; gpa.destroy(self); } return previous - 1; }
fn onDefaultDeviceChanged( iface: *const win32.IMMNotificationClient, flow: win32.EDataFlow, role: win32.ERole, id: ?[*:0]const u16, ) callconv(.winapi) HRESULT { _ = role; _ = id; const self = enter(iface) orelse return com.hresult.s_ok; defer self.leave();
self.sink.deliver(switch (flow) { .eRender => .{ .default_render_changed = true }, .eCapture => .{ .default_capture_changed = true }, else => .{}, }); return com.hresult.s_ok; }
fn onDeviceAdded( iface: *const win32.IMMNotificationClient, id: ?[*:0]const u16, ) callconv(.winapi) HRESULT { _ = id; const self = enter(iface) orelse return com.hresult.s_ok; defer self.leave(); self.sink.deliver(.{ .device_added = true }); return com.hresult.s_ok; }
fn onDeviceRemoved( iface: *const win32.IMMNotificationClient, id: ?[*:0]const u16, ) callconv(.winapi) HRESULT { _ = id; const self = enter(iface) orelse return com.hresult.s_ok; defer self.leave(); self.sink.deliver(.{ .device_removed = true }); return com.hresult.s_ok; }
fn onDeviceStateChanged( iface: *const win32.IMMNotificationClient, id: ?[*:0]const u16, new_state: u32, ) callconv(.winapi) HRESULT { _ = id; _ = new_state; const self = enter(iface) orelse return com.hresult.s_ok; defer self.leave(); self.sink.deliver(.{ .device_state_changed = true }); return com.hresult.s_ok; }
fn onPropertyValueChanged( iface: *const win32.IMMNotificationClient, id: ?[*:0]const u16, key: win32.PROPERTYKEY, ) callconv(.winapi) HRESULT { _ = id; _ = key; const self = enter(iface) orelse return com.hresult.s_ok; defer self.leave(); self.sink.deliver(.{ .property_changed = true }); return com.hresult.s_ok; }};
test "the flag set fits in a word and starts empty" { // It is OR-ed into an atomic `u32` from a notification thread, so its width is // load-bearing rather than incidental. try std.testing.expectEqual(@as(usize, 4), @sizeOf(Flags)); try std.testing.expect(!(Flags{}).any()); try std.testing.expect((Flags{ .default_render_changed = true }).any()); try std.testing.expectEqual( @as(u32, 1), @as(u32, @bitCast(Flags{ .default_render_changed = true })), );}
test "a client can be created, registered, and unregistered" { // Exercises the whole lifetime against the real MMDevAPI, which is the only thing // that can say whether the vtable layout is right: a wrong one would be a crash // inside `RegisterEndpointNotificationCallback` rather than a compile error. var apartment: com.Apartment = try .enter(com.coinit_multithreaded); defer apartment.leave();
var enumerator: com.Enumerator = try .create(); defer enumerator.deinit();
var flags: std.atomic.Value(u32) = .init(0); const client = try NotificationClient.create(std.testing.allocator, .{ .flags = &flags });
try client.register(enumerator); client.unregister(enumerator);
// Nothing was plugged in during the test, so nothing should have been reported -- // which also says the handlers were not called spuriously. try std.testing.expectEqual(@as(u32, 0), flags.load(.acquire));}
test "QueryInterface answers for the interfaces it implements and refuses the rest" { // The COM contract, asserted directly: the two identities it must claim, a null // out-parameter on refusal, and a reference taken on success. var flags: std.atomic.Value(u32) = .init(0); const client = try NotificationClient.create(std.testing.allocator, .{ .flags = &flags }); defer _ = client.interface.IUnknown.Release();
var out: ?*anyopaque = null; try std.testing.expect(com.succeeded(client.interface.IUnknown.QueryInterface( win32.IID_IMMNotificationClient, @ptrCast(&out), ))); try std.testing.expect(out != null); // That query took a reference, so give it back. _ = client.interface.IUnknown.Release();
// And something it is not. out = @ptrFromInt(0xDEAD); const unrelated = Guid.initString("11111111-2222-3333-4444-555555555555"); try std.testing.expect(com.failed(client.interface.IUnknown.QueryInterface( &unrelated, @ptrCast(&out), ))); // Cleared on failure, which is what the contract requires and what the binding's // non-optional parameter type cannot say. try std.testing.expectEqual(@as(?*anyopaque, null), out);}
test "a retired client delivers nothing" { // The guard that makes the lifetime safe: once `alive` is false, a callback that was // already on its way does nothing rather than touching a sink whose owner has gone. var flags: std.atomic.Value(u32) = .init(0); const client = try NotificationClient.create(std.testing.allocator, .{ .flags = &flags }); defer _ = client.interface.IUnknown.Release();
// Alive: the notification lands. _ = NotificationClient.onDeviceAdded(&client.interface, null); try std.testing.expect((@as(Flags, @bitCast(flags.load(.acquire)))).device_added);
flags.store(0, .release); client.alive.store(false, .release);
// Retired: it does not. _ = NotificationClient.onDeviceAdded(&client.interface, null); _ = NotificationClient.onDefaultDeviceChanged(&client.interface, .eRender, .eConsole, null); try std.testing.expectEqual(@as(u32, 0), flags.load(.acquire));
// And nothing was left claimed, so an `unregister` would not spin. try std.testing.expectEqual(@as(u32, 0), client.in_flight.load(.acquire));}
test "a default change is reported per direction" { // The render thread only cares about one of the two, and conflating them would make // a microphone change restart a playback stream. var flags: std.atomic.Value(u32) = .init(0); const client = try NotificationClient.create(std.testing.allocator, .{ .flags = &flags }); defer _ = client.interface.IUnknown.Release();
_ = NotificationClient.onDefaultDeviceChanged(&client.interface, .eCapture, .eConsole, null); var seen: Flags = @bitCast(flags.load(.acquire)); try std.testing.expect(seen.default_capture_changed); try std.testing.expect(!seen.default_render_changed);
_ = NotificationClient.onDefaultDeviceChanged(&client.interface, .eRender, .eConsole, null); seen = @bitCast(flags.load(.acquire)); try std.testing.expect(seen.default_render_changed);}
test "reference counting frees exactly once" { // A leak or a double free here is a use-after-free in somebody else's process, so // the counting is worth asserting rather than assuming. var flags: std.atomic.Value(u32) = .init(0); const client = try NotificationClient.create(std.testing.allocator, .{ .flags = &flags });
try std.testing.expectEqual(@as(u32, 1), client.refs.load(.acquire)); try std.testing.expectEqual(@as(u32, 2), client.interface.IUnknown.AddRef()); try std.testing.expectEqual(@as(u32, 1), client.interface.IUnknown.Release()); // The last release frees it, which the testing allocator checks for us. try std.testing.expectEqual(@as(u32, 0), client.interface.IUnknown.Release());}
test { std.testing.refAllDecls(@This());}