Enhanced logic to treat empty device IDs as wildcard matches for self-set events. Updated notifications to avoid dereferencing potentially invalid pointers, mitigating access violations in specific driver scenarios. |
||
|---|---|---|
| examples | ||
| internal/winmm | ||
| .gitignore | ||
| caerror.go | ||
| com.go | ||
| coreaudio.go | ||
| device.go | ||
| enumerator.go | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| README.md | ||
| selfset.go | ||
| service.go | ||
| types.go | ||
| watcher.go | ||
coreaudio (Windows; Forgejo hosted)
Windows Core Audio device management in Go with minimal COM interop.
Repository/module path: git.callial.com/Callial/coreaudio
Status: Pre-release (breaking changes expected). Phase 1 usable. Device enumeration, default get/set, and event subscription for device/default changes are implemented for Windows.
Current capabilities
- Enumerate audio endpoint devices (Render/Capture), including
ID, friendlyName,Flow, andStatewith default flags - Get the current default device for Console or Communications roles
- Set the default device for a role (via the undocumented
IPolicyConfiginterface) - Subscribe to device events via
IMMNotificationClient:- Default-device changes (Render/Capture; Console/Multimedia/Communications)
- Device added/removed
- Device state changes
- Property changes (key exposed)
Out of scope for now: per-device volume, per-session control.
Requirements
- Windows 10/11
- Go 1.25+
Install
go get git.callial.com/Callial/coreaudio
Quick start
All Windows-specific files use //go:build windows. Build on Windows or with the -tags windows flag.
Service model (library owns COM)
Core Audio is COM-based, but this library now owns COM for you. All Core Audio work is performed on a single dedicated OS thread inside the package. Callers must not initialize COM or pin threads.
Quick start:
package main
import (
"fmt"
"log"
ca "git.callial.com/Callial/coreaudio"
)
func main() {
// Start the internal service (idempotent). Optional but recommended at app init.
if err := ca.EnsureStarted(ca.Options{IgnoreSelfSets: true}); err != nil { log.Fatal(err) }
// Enumerate active render devices
devs, err := ca.ListDevices(ca.Render, ca.Active)
if err != nil { log.Fatal(err) }
fmt.Println("Devices:", devs)
}
Core Audio COM threading and lifetime laws
These rules are mandatory for correctness and stability when using Windows Core Audio (COM):
- Every OS thread that touches COM must call
CoInitializeExfirst, and must callCoUninitializeexactly once when done. - Pick an apartment model and stick to it per thread. Do not mix STA/MTA on the same thread or re-initialize differently later.
- A COM interface pointer is owned by the OS thread/apartment that obtained it. Treat it as thread-affine unless you explicitly marshal it.
- Never use a COM pointer from a goroutine that might run on a different OS thread. In Go, assume goroutines can hop threads unless you prevent it.
- The safest architecture: one dedicated COM thread for all audio work.
- Start a goroutine.
runtime.LockOSThread().CoInitializeEx(...)(usecoreaudio.InitCOM()/coreaudio.UninitCOM()here).- Create/hold/use/release all Core Audio objects on that thread.
- Expose a channel/RPC API for other goroutines to request work.
- Callbacks/notifications must not escape onto random goroutines and then call COM. If you receive a device-change callback, funnel it back into the same COM thread before touching COM again.
- Release is not optional. For every interface you acquire: exactly one
Release()on the same COM thread, including on error paths. - Don’t cache COM pointers across “world changes” unless you refresh safely. Default-device changes, device removal, and policy changes can invalidate assumptions. Prefer re-resolving devices by ID when acting.
- No COM in finalizers for correctness. Finalizers run on arbitrary goroutines/threads. Only use them as a last-ditch leak guard, never as primary cleanup.
- Never let the UI thread call Core Audio directly. UI → send request to COM thread → COM thread does work → send back result.
Examples
See examples/:
list_devices: shows enumeration and default lookup.watch_defaults: prints default change events as they happen.
Run on Windows:
go run ./examples/list_devices
go run ./examples/watch_defaults
Public API
// Device listing
func ListDevices(flow DataFlow, stateMask DeviceState) ([]DeviceInfo, error)
// Default device
func GetDefaultDevice(flow DataFlow, role Role) (*DeviceInfo, error)
func SetDefaultDevice(deviceID string, role Role) error
// Events (subscription)
// Start the internal Core Audio service (idempotent).
func EnsureStarted(opts Options) error
// Subscribe to device/default change events. Returns the channel and a cancel function.
func Subscribe(buf int) (<-chan Event, func())
// Close the internal service (optional; idempotent).
func Close() error
Types and enums are in types.go; devices are described by DeviceInfo.
Event subscription
- Call
EnsureStartedonce at startup to configureOptionslikeIgnoreSelfSetsand the self-suppression window. - Use
Subscribe(buf)to receive events. It returns a buffered channel and acancelfunction; callcancel()to unsubscribe (this closes the channel). Multiple independent subscribers are supported. Self-triggered default-change events (those caused bySetDefaultDevice) can be suppressed by enablingIgnoreSelfSetsviaEnsureStarted.
Implementation notes (Phase 1)
- Minimal COM wrappers live under
internal/winmm/(IMMDeviceEnumerator,IMMDevice,IPropertyStore) - Default get/set uses
IMMDeviceEnumerator.GetDefaultAudioEndpointandIPolicyConfig.SetDefaultEndpoint - An internal service owns a dedicated COM thread, registers an
IMMNotificationClientcallback, and forwards events to subscribers via a non-blocking event bus.
Next steps
- Expand event coverage and metadata (e.g., surface parent IDs on add/remove, expose richer property-change metadata)
- Improve HRESULT-to-error translation with richer context
- Add integration tests on a Windows CI runner and harden edge cases
License
MIT-style license. See LICENSE for full text. Copyright (c) 2025 Callial.