With the volume at zero, my MacBook's speakers were still playing. Quietly enough that you only notice in a quiet room, but they were playing.
Zero is a position, not silence
On a Mac, volume and mute are two separate things. Volume is a number between 0 and 1. Mute is a switch. CoreAudio keeps them as two properties on the output device, and nothing forces them to agree.
The number is a position on the device's range, and the device reports that range in decibels. So I asked the built-in speakers for theirs:
var range = AudioValueRange()
var size = UInt32(MemoryLayout<AudioValueRange>.size)
var a = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyVolumeRangeDecibels,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain)
AudioObjectGetPropertyData(device, &a, 0, nil, &size, &range)
print(range.mMinimum, range.mMaximum) // -63.5 0.0
The bottom of the range is −63.5 dB. That is very quiet. It is not off.
Usually nobody notices, because macOS flips the mute switch as well when the volume arrives at zero. I use an app called Vorssaint with its option "Use finer volume steps" switched on. With that option the volume goes to zero and the mute switch stays where it was.
What I wanted
I wanted the old behaviour back and nothing else. At zero, mute. Above zero, unmute.
The result is one Swift file with 123 lines. It uses only what ships with macOS.
Listening instead of asking
The lazy version is a timer that reads the volume a few times a second. It works, and it wakes up all day long for something that rarely changes.
CoreAudio can call you instead. You name a property on a device and hand over a block:
let listener: AudioObjectPropertyListenerBlock = { _, _ in
if dev == device { check() }
}
var targets = [address(kAudioHardwareServiceDeviceProperty_VirtualMainVolume)]
for element: AudioObjectPropertyElement in [kAudioObjectPropertyElementMain, 1, 2] {
targets.append(address(kAudioDevicePropertyVolumeScalar, element))
targets.append(address(kAudioDevicePropertyMute, element))
}
for var a in targets {
if AudioObjectHasProperty(dev, &a) {
AudioObjectAddPropertyListenerBlock(dev, &a, queue, listener)
}
}
Between two changes the program does nothing at all. The last line of the file is dispatchMain(), and it sits there until CoreAudio has news.
The check itself is short:
func check() {
guard let level = volume(device), let muted = isMuted(device) else { return }
if level <= zero {
if !muted {
setMuted(device, true)
autoMuted = true
log("volume 0, muted device \(device)")
}
} else if autoMuted {
if muted {
setMuted(device, false)
log("volume \(level), unmuted device \(device)")
}
autoMuted = false
}
}
zero is 0.001, not 0. The volume is a float, and a float that is almost zero should count.
Where the small version would have been wrong
The two-line version, mute at zero and unmute above it, breaks in three cases.
Somebody muted on purpose. If I press the mute key and then nudge the volume, a tool that unmutes "whenever the volume is above zero" undoes what I just did. So the tool remembers one thing, autoMuted, and only takes back a mute that it set itself. Everything else it leaves alone.
The output changes. Switch to headphones or a display and the default output is a different device with its own volume and its own mute. A second listener watches the system's default output. When it changes, the tool moves over, forgets autoMuted, and checks the new device. The listeners on the old device stay where they are. Their block compares the device first and does nothing when it is no longer the current one, which was simpler than removing them and getting it wrong.
There is no main mute switch. Some devices have no mute for the device as a whole, only one for each channel. The tool looks for a main mute it is allowed to set. If there is none, it takes channels 1 and 2:
func muteElements(_ dev: AudioDeviceID) -> [AudioObjectPropertyElement] {
for group in [[kAudioObjectPropertyElementMain], [1, 2]] as [[AudioObjectPropertyElement]] {
let found = group.filter { element in
var a = address(kAudioDevicePropertyMute, element)
var settable = DarwinBoolean(false)
return AudioObjectHasProperty(dev, &a)
&& AudioObjectIsPropertySettable(dev, &a, &settable) == noErr && settable.boolValue
}
if !found.isEmpty { return found }
}
return []
}
A device with no mute it can set gets an empty list, and the tool leaves that device alone.
Running it
build.sh compiles the file with swiftc -O, writes a LaunchAgent for your user and starts it. After that it starts at login. It needs no admin rights. uninstall.sh stops it and deletes the LaunchAgent, and that is everything it ever installed.
It writes one line to a log when it starts and one each time it mutes or unmutes. On my machine that log shows 61 mutes so far.
What I have not tested
I wrote this for my own MacBook and run it on macOS 27. I have not tried other versions of macOS or other Macs. The −63.5 dB is what my built-in speakers report, and yours may report something else.
Every entry in my log is for one and the same device, and my built-in speakers have a main mute switch. So following a new output and muting per channel are in the code, but my log does not prove them yet.
The project is not affiliated with Vorssaint.
The code is on GitHub under the MIT license, and it is short enough to read in one sitting: BenCodeX-Dev/vorssaint-stumm.
I built the tool and wrote this post with the help of an AI assistant. The measurements are from my own machine.
Top comments (0)