Describe the bug
After silence! has been running for a while (especially after auto_mute has fired at least once), the mute toggle becomes a silent no-op: clicking the tray icon, pressing a hotkey, or clicking the overlay does not flip the mic, and the visible icon does not change. The tray icon, tray menu label, and overlay can also drift out of sync with the OS mute state when an external app (Teams, Discord, OBS, the Windows sound panel) toggles the mic. Only fully exiting and relaunching silence! recovers the toggle.
This looks closely related to the resolved #33. The 250 ms ID_STATE_TIMER polling is in place, but every error branch on the audio path silently swallows failures, so when polling or a user toggle hits a transient IAudioEndpointVolume error, STATE.muted gets stuck and there is no UI feedback or recovery path.
To Reproduce
Stuck-toggle path:
- Launch silence! v2.1.1 with Auto-Mute → "Mute after inactivity" enabled.
- Let inactivity auto-mute fire at least once, then resume activity.
- Try toggling mute via tray click, hotkey, or overlay button.
- Observed: nothing moves. Tray icon, tray menu label ("Mute Microphone"/"Unmute Microphone"), and overlay icon all stay frozen on the cached state. The OS mic mute does not flip either.
- Quit silence! → relaunch → toggle works again immediately.
External-source desync (close cousin to #33):
- With silence! running, open Microsoft Teams (or Discord) and mute/unmute the microphone from there.
- silence!'s tray icon and overlay do not reliably reflect the change.
Expected behavior
- A failed
set_mute / current_mute_state call should produce visible feedback (toast, overlay flash, or tray balloon) so the user knows the audio API is unhappy and so the app can attempt recovery.
- External mute/unmute events should propagate to silence! immediately (event-driven), not only via 250 ms polling that can itself silently stall on Err.
Desktop
- OS: Windows 11 26200
- App version: silence! v2.1.1 (Rust rewrite, commit
2524ad2)
- Hardware: single USB capture device
Code analysis (static read of main @ 2524ad2, v2.1.1)
Three reinforcing problems:
-
STATE.muted is set in only two places: src/main.rs:2462 (startup) and 4337 (set_global_mute_state). The 250 ms timer registered at 2487 calls refresh_runtime_state() → refresh_mute_state() (5468-5485), which silently returns on current_mute_state() Err at 5469-5471. When the audio API is transiently unhappy, STATE.muted stops updating and nothing surfaces.
-
Every Err branch on the audio path is eprintln! only — no UI feedback, no retry, no fallback. The pattern has grown in v2.1.1:
toggle_mute_target: 4241
set_mute_target: 4252
apply_startup_auto_mute: 4263
start_hold_hotkey: 4271, 4297
start_hold_toggle_hotkey: 4305
release_hold_hotkey: 4333
apply_inactivity_auto_mute: 5518
- new in v2.1.1 (commits
c7d612c, 68dc4b1): default-device set/toggle from menu (3671, 3676) and from hotkey (4195, 4202, 4208, 4213, 4218).
-
There is no Core Audio change subscription anywhere in src/ (no RegisterControlChangeNotify, no IMMNotificationClient, no IAudioEndpointVolumeCallback). External mute/unmute relies entirely on the 250 ms polling, which itself is silent on Err. apply_auto_mute (5522-) and the activity-based unmute path trust set_mute's return value without re-reading GetMute.
The tray icon (refresh_tray_icon at 2786-), tray menu label (3326-3335), and overlay (4471-4509) all paint from the STATE.muted cache. Once the cache is stuck, every visible surface is stuck.
Suggestion
Smallest patch that covers both the stuck toggle and the #33-style external desync:
- Subscribe to mute-change events at startup using
IAudioEndpointVolume::RegisterControlChangeNotify / IAudioEndpointVolumeCallback. The callback's OnNotify should PostMessage(WM_AUDIO_MUTE_CHANGED, ...); route that in main_wnd_proc to refresh_mute_state(). The 250 ms timer can stay as a fallback.
- Make
refresh_mute_state() log the error instead of silently returning:
fn refresh_mute_state() {
let muted = match current_mute_state() {
Ok(muted) => muted,
Err(err) => {
eprintln!("failed to refresh microphone mute state: {err:?}");
return;
}
};
if !muted && STATE.lock().unwrap().auto_muted_by_inactivity {
clear_inactivity_auto_mute_flag();
}
if STATE.lock().unwrap().muted != muted {
set_global_mute_state(muted, true);
}
}
- (Optional, separate PR) Surface toggle Err to the user (overlay flash or tray balloon on the
eprintln! paths above) so silent failure becomes visible.
Happy to send a PR — the RegisterControlChangeNotify boilerplate is ~50 lines under windows-rs.
Thanks for the great utility!
Diagnosed by static read with a Claude + Codex (gpt-5.5) pair; both arrived at the same root cause independently with the same file:line evidence.
Describe the bug
After silence! has been running for a while (especially after
auto_mutehas fired at least once), the mute toggle becomes a silent no-op: clicking the tray icon, pressing a hotkey, or clicking the overlay does not flip the mic, and the visible icon does not change. The tray icon, tray menu label, and overlay can also drift out of sync with the OS mute state when an external app (Teams, Discord, OBS, the Windows sound panel) toggles the mic. Only fully exiting and relaunching silence! recovers the toggle.This looks closely related to the resolved #33. The 250 ms
ID_STATE_TIMERpolling is in place, but every error branch on the audio path silently swallows failures, so when polling or a user toggle hits a transientIAudioEndpointVolumeerror,STATE.mutedgets stuck and there is no UI feedback or recovery path.To Reproduce
Stuck-toggle path:
External-source desync (close cousin to #33):
Expected behavior
set_mute/current_mute_statecall should produce visible feedback (toast, overlay flash, or tray balloon) so the user knows the audio API is unhappy and so the app can attempt recovery.Desktop
2524ad2)Code analysis (static read of
main@2524ad2, v2.1.1)Three reinforcing problems:
STATE.mutedis set in only two places:src/main.rs:2462(startup) and4337(set_global_mute_state). The 250 ms timer registered at2487callsrefresh_runtime_state()→refresh_mute_state()(5468-5485), which silently returns oncurrent_mute_state()Err at5469-5471. When the audio API is transiently unhappy,STATE.mutedstops updating and nothing surfaces.Every Err branch on the audio path is
eprintln!only — no UI feedback, no retry, no fallback. The pattern has grown in v2.1.1:toggle_mute_target:4241set_mute_target:4252apply_startup_auto_mute:4263start_hold_hotkey:4271,4297start_hold_toggle_hotkey:4305release_hold_hotkey:4333apply_inactivity_auto_mute:5518c7d612c,68dc4b1): default-device set/toggle from menu (3671,3676) and from hotkey (4195,4202,4208,4213,4218).There is no Core Audio change subscription anywhere in
src/(noRegisterControlChangeNotify, noIMMNotificationClient, noIAudioEndpointVolumeCallback). External mute/unmute relies entirely on the 250 ms polling, which itself is silent on Err.apply_auto_mute(5522-) and the activity-based unmute path trustset_mute's return value without re-readingGetMute.The tray icon (
refresh_tray_iconat2786-), tray menu label (3326-3335), and overlay (4471-4509) all paint from theSTATE.mutedcache. Once the cache is stuck, every visible surface is stuck.Suggestion
Smallest patch that covers both the stuck toggle and the #33-style external desync:
IAudioEndpointVolume::RegisterControlChangeNotify/IAudioEndpointVolumeCallback. The callback'sOnNotifyshouldPostMessage(WM_AUDIO_MUTE_CHANGED, ...); route that inmain_wnd_proctorefresh_mute_state(). The 250 ms timer can stay as a fallback.refresh_mute_state()log the error instead of silently returning:eprintln!paths above) so silent failure becomes visible.Happy to send a PR — the
RegisterControlChangeNotifyboilerplate is ~50 lines underwindows-rs.Thanks for the great utility!
Diagnosed by static read with a Claude + Codex (gpt-5.5) pair; both arrived at the same root cause independently with the same file:line evidence.