Skip to content

Mute toggle becomes a silent no-op after audio API transient failure (icons stay stuck until restart) #49

Description

@tellang

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:

  1. Launch silence! v2.1.1 with Auto-Mute → "Mute after inactivity" enabled.
  2. Let inactivity auto-mute fire at least once, then resume activity.
  3. Try toggling mute via tray click, hotkey, or overlay button.
  4. 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.
  5. Quit silence! → relaunch → toggle works again immediately.

External-source desync (close cousin to #33):

  1. With silence! running, open Microsoft Teams (or Discord) and mute/unmute the microphone from there.
  2. 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:

  1. 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.

  2. 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).
  3. 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:

  1. 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.
  2. 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);
    }
}
  1. (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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions