WinAudioRoute 0.1.0
dotnet add package WinAudioRoute --version 0.1.0
NuGet\Install-Package WinAudioRoute -Version 0.1.0
<PackageReference Include="WinAudioRoute" Version="0.1.0" />
<PackageVersion Include="WinAudioRoute" Version="0.1.0" />
<PackageReference Include="WinAudioRoute" />
paket add WinAudioRoute --version 0.1.0
#r "nuget: WinAudioRoute, 0.1.0"
#:package WinAudioRoute@0.1.0
#addin nuget:?package=WinAudioRoute&version=0.1.0
#tool nuget:?package=WinAudioRoute&version=0.1.0
WinAudioRoute
Modern Windows audio control for .NET.
- Audio devices — enumerate playback/recording endpoints in any state
- Audio sessions — session-granular enumeration, not just per-process
- Volume / mute — device-level and application-level
- Default devices — read and set, per flow and per role
- Per-app input/output routing — send one application's audio to a chosen device
- Device / session events — push-based change notifications
- CLI + JSON —
winaudio, scriptable from PowerShell, Python, Node.js, AutoHotkey
using WinAudioRoute;
var audio = new WindowsAudioManager();
var devices = audio.GetPlaybackDevices();
var sessions = audio.GetSessions();
audio.SetApplicationVolume(sessions[0].ProcessId, 0.5f);
audio.SetDefaultDevice(devices[0], AudioRole.Console);
winaudio devices
winaudio sessions --json
winaudio volume chrome 35
winaudio route game.exe --output "Speakers"
Status: 0.1.0 pre-release. The source, CLI and samples are complete and the NuGet packages are built and verified, but the package is not published to NuGet.org yet. NuGet package publishing is planned for v0.1.0 — until then, build from source (see Building from source).
Why WinAudioRoute
Windows Core Audio is powerful but awkward to consume from .NET: WASAPI interop is manual, audio sessions are not the same thing as applications, and the API that routes one application's audio to a chosen device is not publicly documented at all.
WinAudioRoute packages that work into a small, documented, dependency-free SDK plus a JSON-capable CLI:
- No UI, no tray, no framework. The library is a plain class library with zero third-party
runtime dependencies. It does not reference WPF, WinForms, or any UI framework.
It also does not reference the WinRT projection:
Windows.Media.Internal.AudioPolicyConfigis reached through directcombase.dllP/Invoke (RoGetActivationFactory,WindowsCreateString), soWinRT.Runtime.dllis never a dependency of your library code. (It may still appear in the output of an executable project, because it ships with thenet8.0-windows10.0.19041.0framework reference itself — that is a property of the TFM, not of this package.) - Session-granular, not application-granular. A process can own many sessions (one per browser tab, one per endpoint). The SDK returns sessions and lets you aggregate; it never silently collapses data.
- Honest about failure. No
bool-and-swallow. Typed exceptions carry the originalHRESULT, multi-target writes return per-target results, and unsupported features are reported through an explicit capability check instead of failing silently later. - Honest about undocumented APIs. Per-app routing uses a Windows internal API. That is stated plainly, detected at runtime, and marked in JSON output — never described as official.
- Machine-consumable. Every CLI command supports
--jsonon stdout with diagnostics on stderr, so PowerShell, Python, Node.js, and AutoHotkey can drive it.
Features
| Area | What you get |
|---|---|
| Devices | Enumerate playback/recording devices in any state (active, disabled, not present, unplugged); friendly names; device states; per-role default flags |
| Default devices | Read and set the default device for Console / Multimedia / Communications, for both playback and recording |
| Device volume | Read/write device-level volume and mute (playback and recording endpoints) |
| Sessions | Enumerate audio sessions (session granularity), map session → PID → process name, read session state/identifiers |
| Application volume/mute | Read/write per-application volume and mute across all of a process's sessions, with per-session results |
| Per-app routing | Route one application's output or input to a specific device; read the current route; reset to "follow system default" |
| Events | DeviceChanged (added/removed/state/default/property) and SessionChanged (created/disconnected/state/volume/…) with event-driven cache invalidation |
| CLI | devices, sessions, default, default-output, default-input, volume, mute, unmute, route, reset, capabilities, with --json |
Installation
Not on NuGet.org yet. NuGet package publishing is planned for v0.1.0. The command below will work once the package is published; until then use Building from source.
# Planned for v0.1.0 — not yet available on NuGet.org
dotnet add package WinAudioRoute
Target framework: net8.0-windows10.0.19041.0. The package declares its Windows platform
requirement, so the compiler warns if a project tries to use it on a non-Windows target.
Building from source
git clone https://github.com/kunkunkunQoQ/WinAudioRoute.git
cd WinAudioRoute
dotnet build WinAudioRoute.sln -c Release
# The CLI lands in src/WinAudioRoute.Cli/bin/Release/net8.0-windows10.0.19041.0/winaudio.exe
.\src\WinAudioRoute.Cli\bin\Release\net8.0-windows10.0.19041.0\winaudio.exe capabilities
# Build a local package and consume it directly
dotnet pack src\WinAudioRoute\WinAudioRoute.csproj -c Release -o .\artifacts
# then, in your own project:
# dotnet add package WinAudioRoute --source <path-to>\artifacts
Requirements: Windows 10 2004 (build 19041) or later, and the .NET 8 SDK.
Quick Start
using WinAudioRoute;
using var audio = new WindowsAudioManager();
// Devices
IReadOnlyList<AudioDevice> outputs = audio.GetPlaybackDevices();
IReadOnlyList<AudioDevice> inputs = audio.GetRecordingDevices();
AudioDevice? defaultOutput = audio.GetDefaultDevice(AudioDataFlow.Render, AudioRole.Console);
foreach (AudioDevice device in outputs)
{
Console.WriteLine($"{device.FriendlyName}");
Console.WriteLine($" id = {device.Id}");
Console.WriteLine($" state = {device.State}");
Console.WriteLine($" default = {device.IsDefault}");
}
// Sessions (session granularity: the same PID can appear several times)
foreach (AudioSession session in audio.GetSessions())
{
Console.WriteLine($"PID {session.ProcessId} ({session.ProcessName}) {session.State} vol={session.Volume}");
}
WindowsAudioManager is IDisposable because it owns COM references (session handles and the
native notification callback). Use using, or call Dispose() when your service shuts down.
Volume is a scalar float in 0.0–1.0. Out-of-range values are clamped, not thrown —
that is the documented contract. Percentage helpers (*Percent methods, AudioVolume) exist for
convenience.
Device API
// Enumerate. states accepts any combination of the four Windows device states.
var all = audio.GetPlaybackDevices(AudioDeviceState.All);
var active = audio.GetPlaybackDevices(); // Active only (default)
var inputs = audio.GetDevices(AudioDataFlow.Capture, AudioDeviceState.Active);
// Default devices: six flow x role combinations.
AudioDevice? console = audio.GetDefaultDevice(AudioDataFlow.Render, AudioRole.Console);
AudioDevice? comms = audio.GetDefaultDevice(AudioDataFlow.Capture, AudioRole.Communications);
// Name/ID resolution: exact ID -> exact name -> unique substring (case-insensitive).
// Ambiguity throws AmbiguousAudioDeviceException with every candidate listed.
AudioDevice speakers = audio.ResolvePlaybackDevice("Speakers");
AudioDevice byId = audio.ResolvePlaybackDevice("{0.0.0.00000000}.{...}");
// Change the system default device.
AudioOperationResult result = audio.SetDefaultDevice(speakers, AudioRole.Console);
if (!result.IsSuccess)
{
Console.Error.WriteLine(result); // Total/Succeeded/Failed + per-target HRESULTs
}
AudioDeviceState.All is 0x0F, matching the official Windows constant
DEVICE_STATEMASK_ALL = 0x0000000F. Passing non-state bits (for example
unchecked((int)0xFFFFFFFF)) is normalized or rejected by the SDK.
Session API
IReadOnlyList<AudioSession> sessions = audio.GetSessions(); // everything
IReadOnlyList<AudioSession> playback = audio.GetSessions(AudioDataFlow.Render);
IReadOnlyList<AudioSession> ofPid = audio.GetSessionsForProcess(1234); // by PID, may be empty
// By process name. "chrome" / "chrome.exe" / "CHROME.EXE" are equivalent.
// If several distinct PIDs share the name, this throws AmbiguousAudioSessionException
// (with the candidate PIDs) instead of picking one.
IReadOnlyList<AudioSession> ofChrome = audio.GetSessionsForProcess("chrome.exe");
A session exposes State, Flow, DeviceId, SessionIdentifier,
SessionInstanceIdentifier, Volume, IsMuted, and IsSystemSoundsSession.
When a session does not expose a volume interface, Volume/IsMuted are null rather than a fake value.
Per-app Routing
if (audio.IsPerAppRoutingSupported)
{
AudioDevice target = audio.ResolvePlaybackDevice("SteelSeries Sonar - Gaming");
// Route one application's output to a device.
AudioOperationResult r = audio.SetApplicationOutput(processId, target);
// Read the current route: null means "no persisted route" = follow system default.
AudioDevice? current = audio.GetApplicationOutput(processId);
// Input routing uses the same API with a capture device.
audio.SetApplicationInput(processId, microphone);
// Reset to "follow the system default device".
audio.ResetApplicationOutput(processId);
audio.ResetApplicationRouting(processId); // both directions
}
⚠️ This feature uses an undocumented Windows API
Read this before relying on it.
- What is used: the WinRT internal class
Windows.Media.Internal.AudioPolicyConfig, invoked through hand-written vtable calls with two interface IIDs (one for Windows 10, one for Windows 11 21H2+). Windows exposes no public API for per-application endpoint selection; this is the only known mechanism, and it is what other community tools use as well. - Is it guaranteed by Microsoft? No. It is not a public, documented, or supported API. Microsoft has made no compatibility promise about its existence, its IIDs, its vtable layout, or its behaviour.
- Tested on: Windows 11 build 26100 (x64) only. See TESTED_ENVIRONMENTS.md.
- What could break: a Windows cumulative update could change the vtable layout or the IIDs. If that happens, routing may stop working or the call may fault.
- How WinAudioRoute mitigates it: availability is probed at runtime before any call
(
IsPerAppRoutingSupported/RoutingCapability), every write is verified by reading the route back, failures surface asAudioRoutingNotSupportedException(with the originalHRESULT) rather than silent no-ops, and the CLI marks all routing JSON with"usesUndocumentedApi": true. nullmeans something specific: passingnullas the device deletes the application's persisted endpoint, i.e. "follow the system default device". This mirrors the underlying native semantics. Prefer the explicitResetApplicationOutput/ResetApplicationInputmethods if you do not likenull.
Events
audio.DeviceChanged += (_, e) => Console.WriteLine(e); // Added/Removed/StateChanged/DefaultChanged/PropertyChanged
audio.SessionChanged += (_, e) => Console.WriteLine(e); // Created/Disconnected/StateChanged/VolumeChanged/...
audio.NotificationHandlerFaulted += (source, ex) =>
log.Warn($"{source} subscriber threw: {ex.Message}");
- Events are raised on COM callback threads. There is no UI-thread assumption; the library does
not depend on a
DispatcherorSynchronizationContext. Marshal to your UI thread yourself. - Subscriber exceptions never cross the COM boundary. They are isolated, and reported through
NotificationHandlerFaulted. If they escaped, they would become unhandled native exceptions. - Do not do heavy work in a handler (no full device/session enumeration, no routing writes, no blocking).
- Subscribing turns the event system on and registers the native session notifications. Device notifications are registered on construction because they drive cache invalidation; without them the caches fall back to a short TTL.
- The library adds no timers and no background polling. Events are push-based; TTL is only a fallback.
CLI
winaudio devices [--output|--input] [--all] [--json]
winaudio sessions [--output|--input] [--json]
winaudio default [--output|--input] [--json]
winaudio default-output <device> [--role <role>] [--yes] [--json]
winaudio default-input <device> [--role <role>] [--yes] [--json]
winaudio volume <process> <0-100> [--json]
winaudio mute <process> [--json]
winaudio unmute <process> [--json]
winaudio route <process> --output <device> [--json]
winaudio route <process> --input <device> [--json]
winaudio reset <process> [--output|--input] [--json]
winaudio capabilities [--json]
winaudio --version
winaudio --help
Diagnostics / testing helper: winaudio first-audible-pid [--json] prints a PID that is safe to
modify for audio testing (it excludes system-critical processes and the calling process). It exists
for scripts/test-real-audio.ps1 and is not part of the general-purpose API surface.
<process> is a PID or a process name (with or without .exe). <device> is matched by exact ID,
exact friendly name, then unique substring.
stdout carries data, stderr carries diagnostics. --json output is always on stdout, so it pipes safely.
Ambiguity is an error, never a guess. Matching several devices or several PIDs prints the
candidates to stderr and exits non-zero. Changing the system default device requires --yes,
because it is a global change that takes effect immediately.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Usage / argument error |
| 3 | Device not found |
| 4 | Ambiguous device (candidates listed on stderr) |
| 5 | Session not found (or ambiguous process name) |
| 6 | Feature not supported on this system |
| 7 | Access denied |
JSON API
Every command supports --json. Output is a stable envelope on stdout:
{
"tool": "winaudio",
"version": "0.1.0",
"command": "devices",
"timestampUtc": "2026-01-01T00:00:00.0000000+00:00",
"usesUndocumentedApi": false,
"data": {
"states": "Active",
"devices": [
{
"id": "{0.0.0.00000000}.{...}",
"name": "Speakers (Realtek)",
"flow": "render",
"state": "Active",
"isActive": true,
"isDefaultConsole": true,
"isDefaultMultimedia": true,
"isDefaultCommunications": false
}
]
}
}
usesUndocumentedApi is true for route and reset. Missing values are emitted as JSON null
rather than omitted, so consumers can distinguish "absent" from "not applicable".
Consuming it
# PowerShell
$devices = (winaudio devices --json | ConvertFrom-Json).data.devices
$devices | Where-Object isDefaultConsole | Select-Object name, id
# Python
import json, subprocess
data = json.loads(subprocess.run(["winaudio", "sessions", "--json"],
capture_output=True, text=True, check=True).stdout)
for s in data["data"]["sessions"]:
print(s["processId"], s["processName"], s["volume"])
// Node.js
const { execFileSync } = require("child_process");
const { data } = JSON.parse(execFileSync("winaudio", ["default", "--json"], { encoding: "utf8" }));
console.log(data.defaults);
; AutoHotkey v2
RunWait A_ComSpec ' /c winaudio mute discord.exe', , "Hide"
Architecture
WinAudioRoute.sln
├─ src/WinAudioRoute ← the SDK (net8.0-windows10.0.19041.0, zero dependencies)
│ ├─ WindowsAudioManager.cs ← the only public entry point
│ ├─ Devices/ ← device enumeration + name/ID resolution
│ ├─ Sessions/ ← process-name resolution
│ ├─ Routing/ ← per-app routing backend + device-ID conversion + capability
│ ├─ Events/ ← change event args + exception-isolating dispatcher
│ ├─ Models/ ← immutable records, enums, results, exceptions
│ ├─ Components/ ← internal: COM services, ownership, caches, callbacks
│ ├─ Interop/ ← internal: WASAPI declarations, P/Invoke, notification interfaces
│ └─ Platform/ ← internal: architecture guard
├─ src/WinAudioRoute.Cli ← the `winaudio` command (no third-party parser)
├─ tests/WinAudioRoute.Tests ← unit + read-only integration + gated mutation tests
├─ samples/ ← DeviceSwitcher, PerAppRouting, SessionMonitor
├─ scripts/ ← test-real-audio.ps1 (real-hardware release gate)
└─ docs/ ← COM reference ownership, cross-checks, tested environments
Key design points:
- One public entry point.
WindowsAudioManageris the only type you need. Services, caches, COM ownership, backends, and interop declarations areinternal. - Explicit COM ownership. One owner per COM reference, released exactly once. The full
audit — including every
QueryInterface,AddRef, andRelease— is in COM_REFERENCE_OWNERSHIP.md. - Synchronous by design. Core Audio and COM are synchronous. The library does not wrap them in
Task.Runor offer aCancellationTokenthat cannot actually cancel anything. Schedule work yourself if you need it. - Capabilities are data, not assumptions.
IsPerAppRoutingSupportedis a real probe (activation + interface validation), not a Windows-version guess.
Platform support
| Target | Status |
|---|---|
| Windows 10 2004+ (build 19041+) x64 | Supported |
| Windows 11 x64 | Supported, primary development and verification platform |
| Windows 11 ARM64 | Build verified; runtime not yet hardware-verified (experimental) |
| x86 / 32-bit | Not supported. Explicitly rejected at construction |
| Non-Windows | Not supported. Explicitly rejected at construction |
| .NET Framework 4.8 | Not supported (planned for later evaluation) |
The library refuses to run in a 32-bit process because its interop layer assumes 64-bit struct layouts; that assumption is asserted at startup and in tests, and failing fast is intentional.
Supported Windows Versions
Declared minimum: 10.0.19041.0 (Windows 10 2004). See
docs/TESTED_ENVIRONMENTS.md for what has actually been verified on
real hardware, capability by capability, and how to contribute a new environment record.
Verification status
Everything stated here was executed; nothing is asserted from the code alone.
| Scope | Command | Result |
|---|---|---|
| Build | dotnet build -c Release |
0 warnings / 0 errors |
| Unit tests (also the CI scope) | dotnet test --filter "Category!=Integration&Category!=Mutation&Category!=Hardware&Category!=RealAudio" |
293 passed / 0 failed |
| Integration tests (real hardware, read-only) | dotnet test --filter "Category=Integration" |
55 passed / 0 failed |
| Mutation tests (real state, restored) | dotnet test --filter "Category=Mutation" (needs RUN_AUDIO_MUTATION_TESTS=1) |
13 passed / 0 failed |
| Environment bypasses | — | 0 — every test exercised its assertions |
| x64 build | dotnet build -r win-x64 |
x64 (PE 0x8664) |
| ARM64 build | dotnet build -r win-arm64 |
ARM64 (PE 0xAA64) |
| NuGet contents | eng/verify-package.ps1 |
PASS |
| XML documentation | eng/verify-xml-docs.ps1 + XmlDocumentationTests |
PASS (608 documented members) |
| Package as a consumer | clean project referencing the packed .nupkg |
build PASS, run PASS |
Reproduce the hardware-dependent rows with pwsh ./scripts/test-real-audio.ps1 on a Windows machine
with audio endpoints. The script prints the OS build, architecture, device counts, per-app routing
availability, the per-suite pass/fail/environment-bypass counts, and then verifies that the
mutation suite restored the default device and per-app routing state.
env-bypassed = 0is the number that matters. xUnit 2.5.3 has noAssert.Skip, so a test that cannot run returns early and is still reported as passed — the bypass count is how many tests did not exercise their assertions. Here it is zero.
ARM64 build verified. Runtime not yet hardware-verified.
Known Limitations
- Per-app routing relies on an undocumented API. See the warning in Per-app Routing. There is no public alternative.
- Changing the system default device is a global side effect and takes effect immediately for
every application. The CLI therefore requires
--yes. - No global "reset all applications" API. WinAudioRoute deliberately exposes only per-process resets. The underlying internal API can clear every application's persisted route at once (and the product it was extracted from additionally deletes a registry key to do so); that is a broad, hard-to-undo change, so it is not part of the public surface.
- ARM64 has not been hardware-verified. It cross-compiles and that is all that is claimed.
- Device volume
Getmay return values slightly above 1.0 on some endpoints; the library clamps them. - Application volume is aggregated: reads return the first session of a process, writes apply to all of them. This is documented behaviour, not an accident.
- No async API yet. If you need background execution, wrap calls yourself.
Compatibility
- Built for .NET 8. The package targets
net8.0-windows10.0.19041.0. - Both the library and the CLI are compiled for
win-x64andwin-arm64. - The public API is annotated with
[SupportedOSPlatform("windows10.0.19041")], so misuse from a cross-platform project is a compile-time warning. - The package ships XML documentation for every public member, plus SourceLink and symbols.
Security
- No network access, no telemetry, no accounts, no cloud, no database. The library makes no outbound connections of any kind.
- No drivers, no hooks, no injection. It uses documented WASAPI/MMDevice APIs plus the internal policy API described above.
- No elevation required for anything in the public API. Operations the current user is not
permitted to perform fail with a typed exception carrying the
HRESULT(E_ACCESSDENIEDmaps to exit code 7 in the CLI). - COM references are released deterministically, including on the disposal paths, and event subscribers cannot crash the process through a callback.
Contributing
Issues and pull requests are welcome.
git clone https://github.com/kunkunkunQoQ/WinAudioRoute.git
cd WinAudioRoute
dotnet restore
dotnet build -c Release
dotnet test -c Release --filter "Category!=Integration&Category!=Mutation&Category!=Hardware&Category!=RealAudio"
- The default test run covers unit tests only, so it works on any machine (including CI without audio hardware).
- Integration tests need real audio devices:
dotnet test -c Release --filter "Category=Integration". - Mutation tests modify real system state and require an explicit opt-in:
$env:RUN_AUDIO_MUTATION_TESTS='1'. They snapshot, modify, verify, and restore infinally. - For a full real-hardware run use
scripts/test-real-audio.ps1. - Please add a record to docs/TESTED_ENVIRONMENTS.md when you verify a new environment, including ARM64 hardware.
CI scope
CI verifies builds, unit tests and packaging. Hardware-dependent Windows audio integration tests are validated separately on real Windows systems.
The default GitHub Actions workflow runs unit tests only. It deliberately excludes
Category=Integration and Category=Mutation (and any future Hardware / RealAudio category),
because a hosted runner has no audio endpoints: those suites would mostly return early and then be
reported as passing, which is a false green. Real audio behaviour is verified on real machines via
scripts/test-real-audio.ps1, and .github/workflows/hardware-tests.yml is available as an opt-in,
manually triggered workflow for a self-hosted runner with audio devices.
Used By
Planned consumer — no project depends on WinAudioRoute yet.
- SonicRoute — per-application Windows audio device switching. SonicRoute is planned to migrate to WinAudioRoute; it has not migrated yet and currently ships its own internal audio core. WinAudioRoute was extracted from it, and SonicRoute's source was not modified during that extraction.
License
MIT. See LICENSE.
WinAudioRoute's audio implementation was extracted from SonicRoute (MIT, Copyright (c) 2026 kunkunkunQoQ). Interop declarations are based on the public Windows SDK headers; the per-app routing approach was informed by community implementations (EarTrumpet, SoundSwitch) without copying their code.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0-windows10.0.19041 is compatible. net9.0-windows was computed. net10.0-windows was computed. |
-
net8.0-windows10.0.19041
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0 | 90 | 9/27/2026 |