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>
This commit is contained in:
132
README.md
Normal file
132
README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user