Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.1] - 2026-11-01

### Fixed

- A Windows or audio driver update that replaced a device's endpoint id broke the toggle with "nothing to switch to: neither ... is connected and enabled", although both devices were connected. Toggle Audio now also saves each device's stable id (Windows 11 24H2 and later). When a saved id stops working it finds the device by stable id, or by its exact name if exactly one connected device has that name, and updates the configuration itself. Existing configurations repair themselves on the next press. The normal toggle path makes no additional calls.
- A device that really cannot be found is no longer reported as disconnected: the message says that Windows no longer has it and to choose a replacement in Settings.
- The settings dialog opens with such a device selected at its new endpoint instead of listing it as "(not connected)".

### Added

- winget manifests for `Hotdogee.ToggleAudio` under `installer/winget/` and a `winget` workflow that submits each new release to microsoft/winget-pkgs.
Expand Down Expand Up @@ -36,5 +44,6 @@ Initial native release; replaces the PowerShell proof of concept (`Switch-Audio.
- Documentation: README with install, hotkey set-up (G HUB and other launchers), command-line and configuration reference, compatibility and troubleshooting notes; `docs/benchmarks.md`; `docs/packaging.md`.
- Continuous integration (format, lint, tests, release build with a static C runtime check, benchmark builds, MSI build and validation) and tag-driven release automation with the MSI, a portable zip and `SHA256SUMS.txt`.

[Unreleased]: https://github.com/hotdogee/toggle-audio/compare/v0.1.0...HEAD
[Unreleased]: https://github.com/hotdogee/toggle-audio/compare/v0.1.1...HEAD
[0.1.1]: https://github.com/hotdogee/toggle-audio/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/hotdogee/toggle-audio/releases/tag/v0.1.0
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "toggle-audio"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
rust-version = "1.85"
description = "Flip Windows audio output between two devices in milliseconds"
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ Toggle Audio does one thing: each run switches the Windows default playback devi
**Verify the download.** Each release publishes `SHA256SUMS.txt` next to the MSI and the zip. The two values must match:

```powershell
(Get-FileHash .\toggle-audio-0.1.0-x64.msi -Algorithm SHA256).Hash.ToLower()
Select-String toggle-audio-0.1.0-x64.msi .\SHA256SUMS.txt
(Get-FileHash .\toggle-audio-0.1.1-x64.msi -Algorithm SHA256).Hash.ToLower()
Select-String toggle-audio-0.1.1-x64.msi .\SHA256SUMS.txt
```

For silent installs (`msiexec /i toggle-audio-0.1.0-x64.msi /qn` from an elevated terminal), installing without the PATH entry, upgrades and uninstalling, see [docs/packaging.md](docs/packaging.md). Your settings in `%APPDATA%\toggle-audio\` are kept on upgrade and uninstall.
For silent installs (`msiexec /i toggle-audio-0.1.1-x64.msi /qn` from an elevated terminal), installing without the PATH entry, upgrades and uninstalling, see [docs/packaging.md](docs/packaging.md). Your settings in `%APPDATA%\toggle-audio\` are kept on upgrade and uninstall.

### Portable zip

Expand Down Expand Up @@ -144,7 +144,7 @@ The two are equally fast (see the [benchmarks](docs/benchmarks.md#g-hub-launch-c
`toggle-audio --help` prints:

```text
toggle-audio 0.1.0
toggle-audio 0.1.1
Flip the Windows default playback device between two configured devices.

Usage:
Expand Down Expand Up @@ -232,12 +232,15 @@ Settings live in `%APPDATA%\toggle-audio\config.json`, one file per Windows user
| --- | --- |
| `version` | Format version, currently `1`. |
| `device1.id`, `device2.id` | Endpoint ids, as printed by `toggle-audio list`. These are what the program uses. |
| `device1.name`, `device2.name` | The friendly name when the device was saved. It is for information only, used in error messages and in the dialog when the device is not connected. |
| `device1.name`, `device2.name` | The friendly name when the device was saved. It is used in error messages, in the dialog when the device is not connected, and as the last resort for finding the device. |
| `device1.stable_id`, `device2.stable_id` | Written by the program. Finds the device when its id stops working. Empty when Windows has none for the device. Leave it out when you edit the file by hand, and the next toggle fills it in. |
| `switch_communications` | `true` (the default when the field is missing) also switches the default communications device. `--comm` and `--no-comm` override it for one run. |

Unknown fields are ignored, and spaces around ids are trimmed.

**Ids, not names.** Windows gives every playback endpoint a stable id. Names can change: you can rename a device in Sound settings, and the "Speakers" part is translated (on a Chinese system it reads `喇叭`). The id stays the same as long as the device uses the same driver and port, so the setting survives renames and language changes. Reinstalling a driver, or plugging a USB device into another port, can create a new endpoint; then choose the device again in Settings.
**Ids, then names.** Devices are saved by their Windows endpoint id, because names can change: you can rename a device in Sound settings, and the "Speakers" part is translated (on a Chinese system it reads `喇叭`). The id is not permanent either: a Windows or audio driver update can give the same device a new one. So the stable id that Windows 11 24H2 and later provide is saved too, and when a saved id stops working, Toggle Audio finds the device by its stable id, or by its exact name if exactly one connected device has that name, and updates the configuration itself. A normal press only uses the saved id, so this costs no time.

If none of that finds the device, for example after you plug a USB device into another port, choose it again in Settings. Toggle Audio does not guess: an unplugged device is never replaced by another one with the same name.

**What a toggle does:**

Expand Down Expand Up @@ -311,7 +314,7 @@ Full results, method and caveats are in [docs/benchmarks.md](docs/benchmarks.md)
| Symptom | What to do |
| --- | --- |
| A device shows as **"(not connected)"** in Settings | Windows reports it as unplugged, off or disabled. Turn it on, or check Sound settings > All sound devices. If it is connected but still listed that way, Windows has created a new endpoint for it (after a driver reinstall or a different USB port): choose it again and Save. |
| A hotkey press shows an **error message box** | The message names the problem. "nothing to switch to" means that neither configured device is available. A missing or broken configuration opens Settings instead: choose the devices, Save, and press the key again (that press does not toggle). |
| A hotkey press shows an **error message box** | The message names the problem. "nothing to switch to" means that neither configured device is available. "Windows no longer has the saved device" means it could not be found by id, stable id or name: open Settings, choose it again and Save. A missing or broken configuration opens Settings instead: choose the devices, Save, and press the key again (that press does not toggle). |
| **SmartScreen** blocks the MSI or the exe | Releases so far are unsigned ([Code signing policy](#code-signing-policy)). Click **More info > Run anyway**, after you have checked the hash against `SHA256SUMS.txt` ([Install](#install)). A signed release can still get this warning for a while after it is published, until SmartScreen has seen enough clean installs. |
| **A console window flashes** on each G HUB press | You are on Windows 10 or Windows 11 before 24H2. Bind `toggle-audiow.exe` instead of `toggle-audio.exe`. |
| Device names show as `?` or garbled text in `toggle-audio list \| ...` | When PowerShell pipes a native program's output, it decodes it with the console code page, so on a non-UTF-8 code page (for example Traditional Chinese, 950) CJK names break. Run `[Console]::OutputEncoding = [Text.UTF8Encoding]::new()` first. Output redirected to a file (`> devices.txt`) is always UTF-8, and plain console output is always correct. Endpoint ids are ASCII, so have scripts match on ids rather than names. |
Expand Down
2 changes: 1 addition & 1 deletion THIRD-PARTY-NOTICES.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Third-party notices for Toggle Audio 0.1.0
Third-party notices for Toggle Audio 0.1.1

toggle-audio.exe and toggle-audiow.exe are licensed under the MIT License (see LICENSE).
They are statically linked with the Rust crates listed below. Each crate is available under
Expand Down
2 changes: 1 addition & 1 deletion assets/app.manifest
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<!-- version is the Cargo.toml version plus ".0"; a unit test (gui::tests) fails when they differ. -->
<assemblyIdentity type="win32" name="Hotdogee.ToggleAudio" version="0.1.0.0" processorArchitecture="*"/>
<assemblyIdentity type="win32" name="Hotdogee.ToggleAudio" version="0.1.1.0" processorArchitecture="*"/>
<!-- Common Controls v6: visual styles for the settings dialog (themed buttons, combo boxes, checkbox). -->
<dependency>
<dependentAssembly>
Expand Down
4 changes: 2 additions & 2 deletions bench/run-bench.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ if ($ToggleRuns % 2) { throw '-ToggleRuns must be even, so every toggle row ends

$Bench = $PSScriptRoot
$Repo = Split-Path -Parent $Bench
$Pg = '{0.0.0.00000000}.{739b3554-bfed-4d61-b407-a818b317c991}' # PG42UQ (NVIDIA High Definition Audio)
$Phl = '{0.0.0.00000000}.{5b124733-5d8f-428c-b83c-ee05ce6467fb}' # PHL BDM4065 (NVIDIA High Definition Audio)
$Pg = '{0.0.0.00000000}.{c9f7cc37-c71e-486e-beee-5010ed8616b7}' # PG42UQ (NVIDIA High Definition Audio)
$Phl = '{0.0.0.00000000}.{6a85b4c1-ef96-4850-bddf-afb4a21bbee2}' # PHL BDM4065 (NVIDIA High Definition Audio)
$Spdif = '{0.0.0.00000000}.{0b317ab3-7e08-4d56-975a-f33b90d5b57a}' # Digital Audio (S/PDIF), legacy script only
$Bogus = '{0.0.0.00000000}.{00000000-0000-0000-0000-000000000000}'
$Legacy = $LegacyExe
Expand Down
26 changes: 21 additions & 5 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ choose_target(current: Option<&str>, device1, device2, is_active: impl Fn(&str)

Mirrors the proof of concept (device1 unless device1 is current) but survives an unplugged device without a silent no-op. `is_active` is `IMMDeviceEnumerator::GetDevice(id)` + `GetState() == DEVICE_STATE_ACTIVE` (GetDevice succeeds for unplugged/disabled devices, so the state check is mandatory).

**Finding the configured devices.** Endpoint ids are not stable. Microsoft documents that an operating system or audio driver update can give the same peripheral a new id, and one did on the reference machine (2026-10-31, build 26300: four of five playback endpoints, all with their stable ids unchanged). Before `choose_target` runs, each configured device is located. Each step runs only when the one before it failed:

1. `GetDevice(id)`. An active endpoint ends the search. This is the hot path and it makes no call that it did not make before.
2. `GetDevice(stable_id)`, with the saved `PKEY_AudioEndpoint_StableId` (`{1da5d803-d492-4edd-8c23-e0c0ffee7f0e},12`, Windows 11 24H2 and later; opaque and case-sensitive). This also runs for a known but inactive endpoint, because an update can leave the old endpoint behind as not present: the endpoint it finds wins only when it is active and different.
3. Only when Windows knows neither id: the exact friendly name, when exactly one active endpoint has it and that endpoint is not the other configured device. Microsoft calls the stable id best effort, so this covers the peripherals whose stable id changes too, and configurations saved before stable ids were recorded. A known but inactive device never reaches this step, so unplugging a device cannot retarget the toggle to an identical one.

After the switch, a device found under a new id is written back (id, live name, fresh stable id) and a missing `stable_id` is recorded once; `""` means Windows has none, so it is not asked again. Neither can delay or fail the switch: a failed read or write is retried on the next toggle. A device that cannot be found is lost, not disconnected, and `NoDeviceAvailable` and the fallback warning say so instead of claiming it is unplugged. The settings dialog opens with the same search applied and reads stable ids on Save.

Roles: `eConsole` and `eMultimedia` always; `eCommunications` when `switch_communications` is true (default true). Order console, multimedia, communications. Skip a role whose current default already is the target.

## 7. Core Audio access (`src/audio.rs`, the only module allowed to touch COM besides the GUI)
Expand All @@ -80,17 +88,25 @@ Roles: `eConsole` and `eMultimedia` always; `eCommunications` when `switch_commu
- Set: `CoCreateInstance(CLSID {870af99c-171d-4f9e-af0d-e63df40c2bc9})` → `IPolicyConfig` `{f8679f50-850a-41cf-9c72-430f290290c8}`, `SetDefaultEndpoint(PCWSTR, ERole)` at 0-based vtable slot 13 (verified against AudioSes.dll symbols and five sources). Fallback if that fails: CLSID `{294935CE-F637-4E7C-A41B-AB255460B862}` → `IPolicyConfigVista` `{568b9108-44bf-40b4-9006-86afe5b5a620}`, slot 12. Declared with `windows_core::interface` as in `docs/research/core-audio-api.md` §C.2 (do not copy the Vista layouts from SoundSwitch/AudioDeviceCmdlets; they carry an extra method).
- Public surface (sketch): `struct Endpoint { id: String, name: String }`, `struct AudioSystem` (owns the COM init guard and enumerator) with `list_active() -> Result<Vec<Endpoint>>`, `default_for(role) -> Result<Option<Endpoint>>`, `is_active(id) -> Result<bool>`, `name_of(id) -> Result<Option<String>>`, `set_default(id, &[Role]) -> Result<()>`. Errors carry the HRESULT and the failing call.

Hot path for `toggle`: parse args → read config → `AudioSystem::new()` → `default_for(Console)` → `choose_target` with `is_active` → `set_default`. No full enumeration.
Hot path for `toggle`: parse args → read config → `AudioSystem::new()` → `default_for(Console)` → `choose_target` with `is_active` → `set_default`. No full enumeration (only step 3 of "Finding the configured devices" in section 6 enumerates, once, when a saved device is lost).

## 8. Configuration (`src/config.rs`)

Path: `%APPDATA%\toggle-audio\config.json` (`SHGetKnownFolderPath(FOLDERID_RoamingAppData)`, falling back to the `APPDATA` variable). Written atomically (write `config.json.tmp`, then `MoveFileExW` replace). Unknown fields ignored; `switch_communications` defaults to true; `name` is informational (used in errors and in the dialog when the device is not connected).
Path: `%APPDATA%\toggle-audio\config.json` (`SHGetKnownFolderPath(FOLDERID_RoamingAppData)`, falling back to the `APPDATA` variable). Written atomically (write `config.json.tmp`, then `MoveFileExW` replace). Unknown fields ignored; `switch_communications` defaults to true; `name` is used in errors, in the dialog when the device is not connected, and as the last resort for finding a device (section 6); `stable_id` is optional and written by the program. It needs no version bump: older builds ignore it.

```json
{
"version": 1,
"device1": { "id": "{0.0.0.00000000}.{739b3554-bfed-4d61-b407-a818b317c991}", "name": "PG42UQ (NVIDIA High Definition Audio)" },
"device2": { "id": "{0.0.0.00000000}.{30045f40-8cfd-4441-bb89-0d13fc19b589}", "name": "喇叭 (FiiO BTA30 PRO)" },
"device1": {
"id": "{0.0.0.00000000}.{739b3554-bfed-4d61-b407-a818b317c991}",
"name": "PG42UQ (NVIDIA High Definition Audio)",
"stable_id": "{0.0.0.00000000}.{001.{31E3A048-D22C-4C75-853B-3DC39707E1A7}}"
},
"device2": {
"id": "{0.0.0.00000000}.{30045f40-8cfd-4441-bb89-0d13fc19b589}",
"name": "喇叭 (FiiO BTA30 PRO)",
"stable_id": "{0.0.0.00000000}.{001.{19DD8A43-B8DD-4C72-B721-7B506462F9FA}}"
},
"switch_communications": true
}
```
Expand Down Expand Up @@ -177,7 +193,7 @@ Decided during implementation and review; the sections above keep the original t

- §4: `toggle-audiow.exe` compiles `assets/app-windowed.rc`, which includes `app.rc` with its own `OriginalFilename`. On Windows versions that ignore `consoleAllocationPolicy` (before 11 24H2), `toggle-audio.exe` frees a console that no other process shares (`GetConsoleProcessList` = 1), so a hotkey launch still reports errors in a message box and opens Settings.
- §5: a toggle without a usable configuration (missing, unreadable or invalid) opens Settings only when there is no console; from a terminal it prints the error with a hint to run `settings` and exits 3. After a first-run Save a message box says to press the hotkey again; that press does not toggle. `set` treats a broken configuration as a warning and uses the default roles. `--timing` starts after parsing (the parsed flag is the only source of truth); stamps still count from process creation. Exit 4 also covers a name that matches several devices: `set` lists the matches (name and id) and asks for the full name or the id. A hotkey press while a Settings dialog is already open (also during first-run setup) brings that dialog to the front and exits 0; only Cancel exits 3. `set` prints the endpoint id when the device has no friendly name.
- §6: "nothing to switch to" names the unavailable device and, when that is the reason, the device that already is the default. Before deciding, the toggle looks up both configured ids (`AudioSystem::lookup`: `GetDevice`, `GetId`, `GetState`), so the comparison with the current default uses Windows' own spelling of each id (another case, or a Windows 11 24H2 stable id that `GetDevice` also accepts, still matches); an id Windows does not know counts as not connected.
- §6: "nothing to switch to" names the unavailable device and, when that is the reason, the device that already is the default. Before deciding, the toggle looks up both configured ids (`AudioSystem::lookup`: `GetDevice`, `GetId`, `GetState`), so the comparison with the current default uses Windows' own spelling of each id (another case, or a Windows 11 24H2 stable id that `GetDevice` also accepts, still matches); a device that cannot be found counts as not active, and the message says Windows no longer has it.
- §7: only `eRender` endpoints are accepted (`IMMEndpoint::GetDataFlow`); a recording endpoint id behaves like an unknown id, so `set` can never change the default microphone. The `IPolicyConfigVista` fallback also covers a failing `IPolicyConfig::SetDefaultEndpoint`, not only a missing class. The hot path reads only the current default's id (`default_id`), not its name, and passes the console default it read on to `set_default`, which skips reading it again (each read is a call into the audio service, about 1.2 ms).
- §8: the `APPDATA` variable is read first and `SHGetKnownFolderPath` is the fallback: it saves about 2 ms per toggle and lets tests redirect the configuration. The atomic replace uses `std::fs::rename` (`MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`). Whitespace around ids is trimmed on load.
- §9: Save closes the dialog at once, so no "Saved to" status is shown. Copy copies the plain, unquoted exe path for the Path field of G HUB's Launch Application action (its Browse button produces the same form; still to be confirmed against G HUB). A named mutex (`Local\Hotdogee.ToggleAudio.Settings`) keeps one dialog per session and brings an open one to the front. The dialog uses `DS_SETFOREGROUND` plus a topmost round trip, a three-line status, and appends the end of the endpoint id to friendly names that occur twice.
Expand Down
Loading
Loading