Skip to content

[Bug] Volume HUD shows 0% after unmuting with the mute key (software-volume devices) #376

Description

@sviperz

Describe the bug

On a device controlled by FineTune's software volume, pressing the mute key to unmute correctly restores the previous volume (audio plays at the right level), but the HUD displays 0%. Cosmetic only, but confusing — it looks like the volume didn't come back when it actually did.

To reproduce

  1. Default output on a software-volume device (e.g. an external monitor), volume at ~60%
  2. Press the mute key (F10) — HUD shows muted
  3. Press it again to unmute
  4. Audio resumes at 60%, but the HUD shows "0%"

MacBook built-in speakers (hardware volume) don't show the problem.

Root cause (found by Claude Code while debugging)

Software-tier mute works by writing the visible volume to 0 (saving the old level) and unmute restores it — both inside DeviceVolumeMonitor.setMute. But MediaKeyMonitor.handleCore's .muteToggle case computes the HUD's slider fraction from a volume snapshot taken before the toggle, so right after unmuting it still shows the muted 0%. Hardware devices never hit this because their volume property stays untouched across mute.

Suggested fix

Give handleCore a way to re-read the volume after the toggle, and use the post-toggle value on unmute (mute keeps showing the pre-mute level, which matches how hardware devices display):

--- a/FineTune/Audio/Keys/MediaKeyMonitor.swift
+++ b/FineTune/Audio/Keys/MediaKeyMonitor.swift
@@ -240,6 +240,7 @@ final class MediaKeyMonitor {
             currentMute: volumeMonitor.muteStates[deviceID] ?? false,
             setVolume: { id, vol in volumeMonitor.setVolume(for: id, to: vol) },
             setMute:   { id, mute in volumeMonitor.setMute(for: id, to: mute) },
+            getVolume: { id in volumeMonitor.volumes[id] ?? 0 },
             playFeedback: { gain in
                 self.feedbackPlayer?.requestFeedback(
                     gain: gain,
@@ -262,6 +263,7 @@ final class MediaKeyMonitor {
         currentMute: Bool,
         setVolume: (AudioDeviceID, Float) -> Void,
         setMute: (AudioDeviceID, Bool) -> Void,
+        getVolume: ((AudioDeviceID) -> Float)? = nil,
         playFeedback: (Float) -> Void = { _ in }
     ) {
         let shouldShowHUD = !popupVisibility.isVisible
@@ -312,7 +314,16 @@ final class MediaKeyMonitor {
             let newMute = !currentMute
             setMute(deviceID, newMute)
             if shouldShowHUD {
-                hudController.show(sliderFraction: currentSlider, mute: newMute, deviceName: deviceName)
+                // Software-tier mute zeroes the visible volume and unmute restores
+                // the saved level inside setMute, so the pre-toggle snapshot reads
+                // 0% after unmuting — re-read to show the restored level.
+                let slider: Double
+                if !newMute, let getVolume {
+                    slider = VolumeMapping.sliderFraction(forSystemGain: getVolume(deviceID), tier: tier)
+                } else {
+                    slider = currentSlider
+                }
+                hudController.show(sliderFraction: slider, mute: newMute, deviceName: deviceName)
             }
             iconCoordinator?.flashDevice()
         }

The getVolume parameter defaults to nil so existing call sites are unaffected. There are two regression tests (spy HUD presenter asserting the exact fraction shown in both mute directions) that I can share if useful.

I'm not a developer so I can't verify Claude's fix. But I tested it locally and confirm it was fixed. You decide to accept the suggested fix or implement your own.

Environment

  • MacBook Air (Apple M5), macOS 27.0 beta (26A5378n)
  • FineTune v1.9.0 (built from source)
  • Monitor: Dell S2725QC over USB-C, software volume backend

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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