A small, dependency-free .NET library for Windows that lists audio endpoints and sets the default playback/recording device, plus volume and mute control.
It replaces the need to host PowerShell and the third-party AudioDeviceCmdlets
module: the same Core Audio (WASAPI) interop is called directly from managed code.
- Target frameworks:
net48,netstandard2.0,net8.0-windows - No NuGet dependencies (pure COM interop).
- Windows only.
Switching the default output device on Windows has no public Win32 API. This library
uses the same, well-established techniques as AudioDeviceCmdlets:
- Enumeration via the documented Core Audio COM interfaces (
IMMDeviceEnumerator, etc.). - Setting the default endpoint via the undocumented
IPolicyConfiginterface.
- Overall library: MIT (see
LICENSE). - The
AudioController/AudioDevicefacade is derived from the MIT-licensedAudioDeviceCmdletsby Francois Gendron. The PowerShell cmdlet layer was not copied; it was replaced with a plain managed API. - The Core Audio (WASAPI) interop is the wrapper by Ray Molenkamp (zlib-style license).
Most of it lives under
CoreAudioApi/; two files are inLib/, because the originalMMDevicewas merged intoLib/AudioDevice.csand the originalMMDeviceEnumeratorintoLib/AudioController.cs.
See THIRD-PARTY-NOTICES.md for the full required notices.
dotnet add package AudioDeviceLib --prerelease
The current release is 1.0.0-rc.2, a release candidate. NuGet does not resolve prereleases by
default, so the --prerelease flag is required — or pin it explicitly:
<PackageReference Include="AudioDeviceLib" Version="1.0.0-rc.2" />There are two ways in: static one-liners for the common cases, and the instance API when you need live objects or repeated operations.
Each static call creates and disposes its own AudioController and device, so you own
nothing and there is nothing to dispose. They hand back AudioDeviceInfo, an immutable
snapshot, rather than a live AudioDevice.
using AudioDeviceLib.Lib;
// Defaults
AudioDeviceInfo playback = AudioController.GetDefaultPlayback();
AudioDeviceInfo recording = AudioController.GetDefaultRecording();
Console.WriteLine(playback.Name);
// Volume (0..100) and mute on the default playback device
float volume = AudioController.GetVolume();
AudioController.SetVolume(50f);
bool muted = AudioController.IsMuted();
AudioController.SetMute(false);
bool nowMuted = AudioController.ToggleMute();
// ...or on a specific endpoint
AudioController.SetVolume(playback.Id, 25f);
// Active endpoints, as snapshots
IReadOnlyList<AudioDeviceInfo> all = AudioController.ListDevices();
IReadOnlyList<AudioDeviceInfo> outputs = AudioController.ListDevices(AudioDeviceKind.Playback);
// Make the first device whose name contains "Speakers" the default.
// Returns null if nothing matches, so you can fall back or report clearly.
AudioDeviceInfo set = AudioController.SetDefaultPlaybackByName("Speakers");
if (set == null)
{
Console.Error.WriteLine("No matching playback device found.");
}These create a controller per call. For repeated work — polling, or several operations on the same device — use the instance API below.
using AudioDeviceLib.Lib;
// AudioController is IDisposable — wrap it in a using so it is torn down deterministically.
using var audio = new AudioController();
// List active playback devices. AudioDevice is IDisposable; dispose what you enumerate.
foreach (var d in audio.GetPlaybackDevices())
{
Console.WriteLine(d); // "Speakers (Realtek...) (Playback) [Default]"
d.Dispose();
}
// Current default output.
using (AudioDevice current = audio.GetDefaultPlaybackDevice())
{
if (current != null)
{
// Volume / mute
current.SetVolumePercent(50f);
current.IsMuted = false;
// Set a specific device you already resolved (default = Multimedia + Communications)
audio.SetDefaultDevice(current);
// Or pick exactly which Windows roles to assign (flags can be combined)
audio.SetDefaultDevice(current, DefaultRole.Console | DefaultRole.Multimedia);
audio.SetDefaultDevice(current, DefaultRole.All);
// Detach an immutable snapshot you can keep after the device is disposed
AudioDeviceInfo snapshot = current.ToDeviceInfo();
// Volume, sessions, metering and the raw property store hang off the device
current.Volume.VolumeStepUp();
var sessions = current.SessionManager.Sessions;
float peak = current.GetPeakValue();
}
}Id, Name, Kind and State are captured when the device is created, so reading them
costs nothing and still works after the device is disposed. Call Refresh() to re-read
Name/State, or register for device notifications (below) to be told when they change.
Devices compare by endpoint ID, so a.Equals(b) is true for two handles on the same
endpoint — reference equality never is.
GetDevices on the instance API also takes DataFlowFilter and DeviceStateFilter
(in AudioDeviceLib.CoreAudioApi) if you need endpoints that are disabled, not present
or unplugged — the static ListDevices returns active endpoints only.
To resolve one endpoint by ID, GetDeviceById returns a live AudioDevice and
GetDeviceInfo an AudioDeviceInfo snapshot:
using (AudioDevice device = audio.GetDeviceById(id))
{
bool active = device.IsActive; // shorthand for State == DeviceState.Active
}
AudioDeviceInfo info = audio.GetDeviceInfo(id);Both reject a null or empty ID with ArgumentNullException. A malformed ID throws
ArgumentException; an ID that is well-formed but matches no endpoint throws COMException.
Types which implements IDisposable. Dispose each one you obtain, with using or by calling
.Dispose():
AudioControllerAudioDevice- the token returned by
AudioController.RegisterDeviceNotification - the token returned by
AudioSessionControl.RegisterAudioSessionNotification
Disposing an AudioDevice also disposes its Volume (AudioEndpointVolume),
SessionManager (AudioSessionManager), SessionManager.Sessions (SessionCollection)
and every AudioSessionControl in that collection.
Disposing twice is a no-op. Afterwards, members that call into Core Audio throw
ObjectDisposedException on both AudioController and AudioDevice; the AudioDevice
identity snapshot (Id, Name, Kind, State, ToDeviceInfo(), ToString(),
Equals()) stays readable.
The static methods return AudioDeviceInfo snapshots and leave nothing to dispose.
PropVariant is not IDisposable but owns native memory: call .Clear() on one returned by
PropertyStore.TryGetValue or PropertyStore.GetValue(int). The PropertyStore indexers return
PropertyStoreProperty, which already does this.
RegisterAudioSessionNotification returns an IDisposable token; wrap it in a using
so the callback is unregistered deterministically when you are done listening.
Threading: callbacks are raised by Windows Core Audio on arbitrary, non-UI threads and may arrive concurrently. Keep each handler fast and thread-safe.
using AudioDeviceLib.Lib;
using AudioDeviceLib.CoreAudioApi;
// Implement the attribute-free contract for the events you care about. Every callback receives an
// immutable AudioSessionInfo snapshot of the source session (identifiers, display name, icon path,
// state), so one consumer can serve many sessions — and there is no live COM object to misuse from
// the notification thread.
sealed class SessionLogger : IAudioSessionEvents
{
public void OnSimpleVolumeChanged(AudioSessionInfo session, float newVolume, bool newMute, Guid eventContext)
{
Console.WriteLine($"pid={session.ProcessID} volume={newVolume:P0} muted={newMute}");
}
public void OnStateChanged(AudioSessionInfo session, AudioSessionState newState)
{
Console.WriteLine($"pid={session.ProcessID} state={newState}");
}
// The remaining IAudioSessionEvents members can be left as no-ops.
public void OnDisplayNameChanged(AudioSessionInfo session, string newDisplayName, Guid eventContext) { }
public void OnIconPathChanged(AudioSessionInfo session, string newIconPath, Guid eventContext) { }
public void OnChannelVolumeChanged(AudioSessionInfo session, float[] newChannelVolumes, uint changedChannel, Guid eventContext) { }
public void OnGroupingParamChanged(AudioSessionInfo session, Guid newGroupingParam, Guid eventContext) { }
public void OnSessionDisconnected(AudioSessionInfo session, AudioSessionDisconnectReason disconnectReason) { }
}using var audio = new AudioController();
var logger = new SessionLogger();
using (AudioDevice device = audio.GetDefaultPlaybackDevice())
{
if (device != null)
{
SessionCollection sessions = device.SessionManager.Sessions;
// Register on every current session; keep the tokens so they can be disposed.
var tokens = new List<IDisposable>();
for (int i = 0; i < sessions.Count; i++)
{
tokens.Add(sessions[i].RegisterAudioSessionNotification(logger));
}
try
{
Console.WriteLine("Listening... press Enter to stop.");
Console.ReadLine();
}
finally
{
// Dispose each token to unregister the callbacks.
foreach (IDisposable token in tokens)
{
token.Dispose();
}
}
}
}For a single session you can inline the using:
using (IDisposable token = session.RegisterAudioSessionNotification(logger))
{
// ...callbacks fire here...
} // token.Dispose() unregisters the callbackDisposing the owning AudioDevice (or AudioSessionControl) also unregisters any
still-active callbacks as a safety net, so the tokens are the deterministic path and
disposal is the backstop.
To be told when endpoints are added/removed, change state, or when the default device
(including the default communications device) changes, register an IAudioDeviceEvents
consumer on the controller. Like session notifications, registration returns an IDisposable
token; disposing it (or the controller) unregisters.
using AudioDeviceLib.Lib;
using AudioDeviceLib.CoreAudioApi;
sealed class DeviceLogger : IAudioDeviceEvents
{
public void OnDefaultDeviceChanged(DataFlow flow, Role role, string defaultDeviceId)
{
// role distinguishes the communications default from the console/multimedia default;
// defaultDeviceId is null when there is no longer a default for this flow/role.
Console.WriteLine($"default {flow}/{role} -> {defaultDeviceId ?? "(none)"}");
}
// The remaining IAudioDeviceEvents members can be left as no-ops.
public void OnDeviceStateChanged(string deviceId, DeviceState newState) { }
public void OnDeviceAdded(string deviceId) { }
public void OnDeviceRemoved(string deviceId) { }
public void OnPropertyValueChanged(string deviceId, PropertyKey key) { }
}using var audio = new AudioController();
using (IDisposable token = audio.RegisterDeviceNotification(new DeviceLogger()))
{
// ...callbacks fire here (on arbitrary, non-UI threads)...
} // token.Dispose() unregistersThreading: callbacks arrive on arbitrary, non-UI threads and may be concurrent. Keep handlers fast and thread-safe, and don't register/unregister or dispose the controller from inside a callback.
Windows tracks three independent default-device roles. DefaultRole is a [Flags]
enum, so you can combine them; each set flag maps to one Role assignment.
| Flag | Maps to (Role) |
Notes |
|---|---|---|
Console |
Role.Console |
System sounds, games, voice commands |
Multimedia |
Role.Multimedia |
Music and movies. Matches -DefaultOnly |
Communications |
Role.Communications |
Voice chat. Matches -CommunicationOnly |
Default (= Multimedia | Communications) |
both | The default; matches Set-AudioDevice with no switch |
All (= Console | Multimedia | Communications) |
all three | Make this THE default for everything |
SetDefaultDevice defaults to DefaultRole.Default, which (like the original cmdlet)
does not set the Console role. If some applications on your target follow the
Console role, pass DefaultRole.All or include DefaultRole.Console in the combination.
- Endpoint friendly names are localized by Windows (e.g. "Reproduktory" on Czech/Slovak systems, "Lautsprecher" on German). Do not hard-code the English word "Speakers" when matching across a mixed fleet; match on a stable substring, or select by kind and default state instead.
- COM must be usable on the calling thread. In a plain console/service this works out of the box.
dotnet build
dotnet test Library.UnitTests/Library.UnitTests.csproj -c Release
The suite runs three times, once per shipped asset: net48, net7.0-windows (which is how the
netstandard2.0 build gets executed — netstandard cannot be targeted directly) and
net8.0-windows. One test asserts which asset each leg actually loaded, so a silent collapse onto a
single build would fail rather than pass quietly.
Tests are read-only: nothing changes volume, mute or the default device. Tests that need a real
endpoint skip themselves on a machine with no audio hardware, so the suite is still meaningful on a
headless CI runner — what it covers there is COM activation, enumeration and the deterministic
Marshal.ReleaseComObject paths.