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
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,51 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

### Fixed

- **Switch output when off stopped working, permanently and silently.** The record of where
the sound came from doubles as the "we owe you a move back" flag, and its presence was
read as "already switched". Endpoint ids do not live forever — a reinstalled driver or a
re-enumerated dongle retires one — so once that record named an endpoint the machine no
longer had, it could never be discharged and never be replaced. Every later power-off and
power-on did nothing at all. A record now counts only while its endpoint still exists,
and one that never reappears is given up on after about ten seconds rather than being
kept forever.

### Added

- **The output switch says when it can't do its job.** No device chosen, the chosen device
unplugged, Windows refusing the change, or the device you were on having disappeared: each
raises a notification the first time and stays on the settings row until it clears.
Previously all four were `tracing` calls at a level no subscriber was listening to.
- `headset-tray.exe --explain-output`, which reports what the switch would do for a
powered-off and a powered-on headset, and why, without moving anything.
- **Split game and chat**, off by default. The headset presents two playback endpoints and
Windows keeps a separate default for calls, so the two exist precisely to be pointed at
different roles. Putting the sound *back* the way it was found cannot do that — it names
one endpoint — and setting that one endpoint into all three of Windows' roles overwrote a
communications default aimed at the chat channel, so the voices came out of the game
channel. Turn the setting on, pick a **Game channel** and a **Chat channel**, and a
headset coming back gets ordinary sound on the first and calls on the second. Left off,
nothing about the switch changes.

## [0.1.0-alpha.2] - 2026-08-05

### Fixed

- **The game/chat slider ran backwards.** The panel labelled low values CHAT and high
values GAME, so dragging toward GAME moved the mix toward chat. `0x00` is full game and
`0x14` full chat — a direction the captures never established, since they record the
range and the clamps but not which end is which.

### Added

- **Switch output when off.** Turning the headset off moves Windows' sound to a
device you choose, and turning it back on puts it where it was. Off by default. The
trigger is the headset's link state — no power-button event exists in the protocol — so
an auto-sleep or going out of range counts too, debounced by two seconds. Setting the
default output has no documented Windows API; see
[`docs/undocumented-apis.md`](docs/undocumented-apis.md).
- A light theme. It follows your Windows setting by default, with an **Appearance** override
in settings. High contrast still wins over both.

Expand Down
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,27 @@ target, and this is not incidental:
If you need to update a dependency, check that the whole tree stays off `raw-dylib`
before proposing it.

Two things follow from that target and neither is obvious from a failed build:

- **You need MinGW-w64 on `PATH`.** The GNU target links with `gcc`, and rustup does not
supply one. `winget install BrechtSanders.WinLibs.POSIX.MSVCRT` is enough; take the
MSVCRT runtime variant, which is the C runtime this target links against. Without it the
build stops at `linker 'gcc' not found`.
- **Make GNU your default host, not just an installed target.** `rustup target add` alone
is not sufficient: build scripts and proc macros compile for the *host* triple, so a
stock MSVC-host rustup fails with `linker 'link.exe' not found` before it reaches any of
this project's code — and installing Visual Studio to satisfy that is the wrong fix.

```powershell
rustup set default-host x86_64-pc-windows-gnu
cargo build --workspace
cargo test --workspace
```

CI gets away without the first step because GitHub's Windows runners ship MinGW-w64
already, and without the second because it passes `--target x86_64-pc-windows-gnu`
explicitly while running on a host that has both linkers.

Most tests need no hardware: the device layer has a fixture-driven fake backend, and the
panel layout, protocol codec, and placement geometry are pure and fully unit-tested. The
tests that do need a headset are `#[ignore]`d *and* gated behind an environment variable,
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

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

10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ members = [
]

[workspace.package]
version = "0.1.0-alpha.1"
version = "0.1.0-alpha.2"
edition = "2021"
# Matches the toolchain pinned in rust-toolchain.toml, which is what CI builds
# and tests with. Not an independently verified floor: no job builds against an
Expand Down Expand Up @@ -73,6 +73,14 @@ windows = { version = "0.58", features = [
"Win32_System_Registry",
"Win32_Media_Audio",
"Win32_Media_Audio_Endpoints",
# Listing playback devices and naming them, for the switch that moves the
# sound off a powered-down headset. Features of the pinned 0.58, not
# crates: `IMMDevice::OpenPropertyStore` needs the property-system feature
# and the friendly-name key lives under function discovery. Setting the
# default output has no binding here and no documentation anywhere -- see
# `crates/headset-tray/src/win32/audio.rs`.
"Win32_Devices_FunctionDiscovery",
"Win32_UI_Shell_PropertiesSystem",
# DPI awareness. A feature of the pinned 0.58, not a crate. The manifest
# route is unavailable on x86_64-pc-windows-gnu, which has no
# /MANIFEST:EMBED, so the process opts in at runtime through
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,43 @@ with what you asked for, you see the truth.
Right-click the icon for Refresh and Exit. The gear opens settings: start with Windows, and
whether to warn you when Synapse is running (it can fight this app for the same settings).

**Switch output when off** moves Windows' sound to another device when the
headset goes quiet, and puts it back when the headset returns. Turn it on in settings and
pick the device under **Play through** — your speakers, usually. It's off until you do
both.

Holding the power button is what turns the headset off, and that's what this reacts to.
The headset reports that its link dropped, not *why*, so an auto-sleep or walking out of
range looks the same and will also move your sound. Brief dropouts won't: nothing moves
until the link has held its new state for a couple of seconds.

It puts you back exactly where you were, not on a guess — the dongle presents two
playback devices (Game and Chat) and only you know which one you were using. If the tray
is closed or restarted while your headset is off, it still remembers what it owes you.

Windows offers no supported way for a program to change the default playback device, so
this uses the same undocumented interface every audio switcher uses. If it ever stops
working, nothing moves and you're left where you were.
[`docs/undocumented-apis.md`](docs/undocumented-apis.md) is the full record.

**When it doesn't move your sound, it says so.** You get a notification the first time,
and the reason stays on the **Switch output when off** row in settings until it works
again. For the full picture — which device is chosen, which one you're owed a move back to,
and what would happen right now — run:

```powershell
headset-tray.exe --explain-output
```

It only reads; it won't move anything.

**Split game and chat** is a separate switch, off by default. The headset shows up in
Windows as two playback devices — a game channel and a chat channel — and Windows keeps a
separate default for calls, so the two are there to be used for different things. Turn it
on, pick a **Game channel** and a **Chat channel**, and whenever the headset powers on your
ordinary sound goes to the first and your calls to the second. Leave it off and the tray
won't touch which device your calls use.

The panel **follows your Windows light or dark setting** by default. **Appearance** in
settings overrides that — auto, dark, or light. If you use Windows high contrast, it wins
over all three.
Expand Down
78 changes: 78 additions & 0 deletions crates/headset-tray/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

#![cfg_attr(not(windows), forbid(unsafe_code))]

pub mod output;
pub mod state;
pub mod ui;
pub mod worker;
Expand All @@ -21,6 +22,7 @@ pub mod settings;
#[cfg(windows)]
pub mod win32;

pub use output::Slot;
pub use state::HeadsetState;
pub use worker::Command;

Expand Down Expand Up @@ -49,6 +51,52 @@ pub fn settings_show_warning() -> bool {
settings::show_synapse_warning()
}

#[cfg(windows)]
pub fn settings_switch_output() -> bool {
settings::switch_output_when_off()
}

/// Whether the headset's game and chat channels are set apart when it returns.
#[cfg(windows)]
pub fn settings_split_game_and_chat() -> bool {
settings::split_game_and_chat()
}

/// What stopped the output switch or the split working last time, if anything.
///
/// The panel reads this rather than being told: the failure it describes
/// happened while the panel was closed, which is the whole reason it is
/// written down.
#[cfg(windows)]
pub fn settings_output_problem() -> Option<output::Problem> {
settings::output_problem()
}

/// A chosen device as `(name, is it plugged in right now)`.
///
/// Presence is resolved here rather than in `layout` because it takes a Core
/// Audio enumeration; the wording built from it stays pure and testable in
/// `layout::output_choice_subtitle`.
#[cfg(windows)]
pub fn settings_output_choice(slot: Slot) -> Option<(String, bool)> {
let (id, name) = settings::output_choice(slot)?;
Some((name, win32::audio::is_present(&id)))
}

#[cfg(windows)]
pub fn settings_output_choice_id(slot: Slot) -> Option<String> {
settings::output_choice(slot).map(|(id, _)| id)
}

/// Every playback device, as `(endpoint id, name)`.
#[cfg(windows)]
pub fn output_devices() -> Vec<(String, String)> {
win32::audio::render_outputs()
.into_iter()
.map(|d| (d.id, d.name))
.collect()
}

#[cfg(not(windows))]
pub fn settings_run_on_startup() -> bool {
false
Expand All @@ -63,3 +111,33 @@ pub fn settings_show_warning() -> bool {
pub fn warn_vendor_software() -> bool {
false
}

#[cfg(not(windows))]
pub fn settings_switch_output() -> bool {
false
}

#[cfg(not(windows))]
pub fn settings_split_game_and_chat() -> bool {
false
}

#[cfg(not(windows))]
pub fn settings_output_problem() -> Option<output::Problem> {
None
}

#[cfg(not(windows))]
pub fn settings_output_choice(_slot: Slot) -> Option<(String, bool)> {
None
}

#[cfg(not(windows))]
pub fn settings_output_choice_id(_slot: Slot) -> Option<String> {
None
}

#[cfg(not(windows))]
pub fn output_devices() -> Vec<(String, String)> {
Vec::new()
}
Loading