Files
audioswitch/README.md
Vincent Lannurien 77be2c188d Cycle the Windows default audio output from a global hotkey
A single 306 KB GUI-subsystem binary with no tray icon and no settings
window: one hidden popup owns the hotkey, the device-change callbacks and
the flyout, and the process sleeps in GetMessageW at 0% CPU in between.

Windows exposes no public API for setting the default audio endpoint, so
this uses the undocumented IPolicyConfig COM interface, confined to
src/policy.rs. Verified working on build 26200. Everything else -
enumeration, IMMNotificationClient, RegisterHotKey, the flyout - is
documented Win32.

The flyout uses the DWM acrylic backdrop and rounded corners the shell
uses for its own flyouts, with text drawn via DrawThemeTextEx and
DTT_COMPOSITED into a 32-bpp DIB, which is what keeps GDI from leaving
black fringes on a composited surface.

Console and Multimedia roles move by default; Communications stays put so
voice chat keeps its headset. Config is a hand-parsed INI reloaded on the
next keypress, keeping the dependency list to the windows crate alone.

Built by cross-compiling from a devcontainer with mingw-w64, so nothing is
installed on the Windows host and the exe imports only system DLLs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 07:29:04 +02:00

133 lines
5.2 KiB
Markdown

# 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.
```bash
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**:
```bash
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:
```powershell
& "$env:LOCALAPPDATA\AudioSwitch\audioswitch.exe" # run in the background
& "$env:LOCALAPPDATA\AudioSwitch\audioswitch.exe" --list # and so on
```
or from `cmd.exe`:
```bat
"%LOCALAPPDATA%\AudioSwitch\audioswitch.exe" --list
```
That gets old fast. Pick one of these once and the short commands below work as written:
```powershell
# A profile function - run `notepad $PROFILE` and add:
function audioswitch { & "$env:LOCALAPPDATA\AudioSwitch\audioswitch.exe" @args }
```
```powershell
# ...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.
```ini
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`](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.