khannurien a049fb7c6b Re-enumerate outputs through a fresh enumerator on each hotkey press
The enumerator created at startup could go stale and keep reporting the
device set from back then, so an unplugged DAC stayed in the cycle and a
replugged one never came back. A failed enumeration now empties the list
instead of keeping the old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 13:24:01 +00:00
2026-09-17 07:29:41 +02:00

win-audio-output-switcher

A global hotkey that cycles the Windows 11 default audio output, and a flyout that tells you where you landed.

No tray icon, no window, no settings dialog. One 300 KB executable that sits in GetMessageW at 0% CPU until you press the key.

Ctrl+Alt+A  ->  Casque (FiiO USB DAC)
Ctrl+Alt+A  ->  Haut-parleurs (Focusrite USB Audio)
Ctrl+Alt+A  ->  Haut-parleurs (Steam Streaming Speakers)

Behaviour

  • One hotkey, forward through a list. Configure which outputs are in the cycle and in what order, or leave it unconfigured and cycle everything that is plugged in.
  • Console and Multimedia roles only. Windows keeps three separate defaults; the Communications one is left alone by default, so a headset stays pinned for voice chat while media follows the hotkey. Change it with roles = if you disagree.
  • Stays in sync. Switch outputs from the Windows volume flyout, or unplug a DAC, and the next press continues from what Windows actually has selected.
  • Never steals focus. The flyout is click-through and non-activating, so pressing the hotkey mid-game or mid-sentence does not pull you out of what you were doing.

Build

Everything happens in the devcontainer — nothing is installed on the Windows side, and the resulting binary imports only system DLLs.

cargo build --release
# -> target/x86_64-pc-windows-gnu/release/audioswitch.exe

The container cross-compiles with mingw-w64 but cannot launch a Windows process, so deploying is a host-side step. From a WSL shell outside the container:

scripts/deploy.sh      # copies to %LOCALAPPDATA%\AudioSwitch and (re)starts it

Use

deploy.sh puts the binary at %LOCALAPPDATA%\AudioSwitch\audioswitch.exe. It is not on PATH, so out of the box every invocation is a full path. In PowerShell:

& "$env:LOCALAPPDATA\AudioSwitch\audioswitch.exe"            # run in the background
& "$env:LOCALAPPDATA\AudioSwitch\audioswitch.exe" --list     # and so on

or from cmd.exe:

"%LOCALAPPDATA%\AudioSwitch\audioswitch.exe" --list

That gets old fast. Pick one of these once and the short commands below work as written:

# A profile function - run `notepad $PROFILE` and add:
function audioswitch { & "$env:LOCALAPPDATA\AudioSwitch\audioswitch.exe" @args }
# ...or put the folder on your user PATH (affects new terminals only):
$old = [Environment]::GetEnvironmentVariable('Path', 'User')
[Environment]::SetEnvironmentVariable('Path', "$old;$env:LOCALAPPDATA\AudioSwitch", 'User')

Verbs

audioswitch                  Run in the background (single instance)
audioswitch --list           List outputs and their endpoint IDs
audioswitch --config         Create/open the config file
audioswitch --quit           Stop the running instance
audioswitch --autostart on   Start with Windows (off to undo)

--autostart records the full path of the exe as it was when you ran it, so it does not care about PATH either way.

The verbs that print borrow the terminal they were launched from. Because this is a GUI-subsystem binary, a shell may hand back the prompt before the output arrives; pipe it (audioswitch --list | Out-String) if the interleaving bothers you.

Config

%APPDATA%\win-audio-output-switcher\config.ini, created by --config already populated with the outputs you have right now. Edits apply on the next hotkey press — no restart.

hotkey = ctrl+alt+a
roles = console,multimedia
osd_position = bottom-center     ; top-center, center, bottom-right, top-right
osd_duration_ms = 1400
osd_fade = false

; Cycle order. Comment out a line to drop that output.
; Delete the whole section to cycle every active output instead.
[devices]
{0.0.0.00000000}.{a51fdbbf-...} = Casque (FiiO USB DAC-E07K)
{0.0.0.00000000}.{ff879682-...} = Haut-parleurs (Focusrite USB Audio)

osd_fade is off by default. Fading needs WS_EX_LAYERED, which on some builds suppresses the acrylic backdrop that makes the flyout look like part of the shell. Turn it on and see which you prefer on your machine.

Known limitations

  • Elevated windows swallow the hotkey. RegisterHotKey does not fire while a window running as administrator has focus, unless this app is elevated too. Inherent to the API.
  • WASAPI exclusive-mode apps do not follow. Some DAWs and games hold an endpoint open exclusively and keep playing to the old device until restarted.
  • Exclusive-fullscreen games hide the flyout. The switch still happens; a topmost window simply cannot draw over an exclusive-fullscreen swap chain.

The undocumented bit

Windows exposes no public API for setting the default audio endpoint. Like every other tool in this space, this one uses the undocumented IPolicyConfig COM interface (CLSID {870af99c-…}, IID {f8679f50-…}). It has worked from Vista through current Windows 11 builds, but it is not contractual.

It is confined to src/policy.rs — about forty lines, all of it the vtable declaration. Everything else (enumeration, change notifications, the hotkey, the flyout) is documented Win32. If a Windows update ever breaks this tool, that is the file to look at first.

Description
🔊 quickly switch audio output under windows
Readme MIT 60 KiB
Languages
Rust 97.6%
Shell 2.1%
Dockerfile 0.3%