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>
133 lines
5.2 KiB
Markdown
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.
|