From 0588ec7ace13377ee991087a7b6d725b2864e614 Mon Sep 17 00:00:00 2001 From: Arjun Khoosal Date: Mon, 25 May 2026 12:31:02 +0200 Subject: [PATCH] fix: use CoreAudio mute property for Aggregate Devices AppleScript `set volume input volume 0` is a no-op on Aggregate Devices, so the menu bar icon would briefly flash red and then revert to unmuted within a second (the watchdog timer reads the still-high volume back and forces the UI to "unmuted"). Replace the mute path with CoreAudio: resolve the default input device, and for an aggregate, dispatch `kAudioDevicePropertyMute` (and `VolumeScalar` for the slider) to each input-bearing sub-device. Master element first, then per-channel fallback; if the device exposes no writable mute property, fall back to driving volume to 0. Also update the 1-second watchdog and `getCurrentVolume` to read the real CoreAudio mute/volume state instead of the AppleScript value (which on aggregates is often a stale 100). --- .../toggleMute.xcodeproj/project.pbxproj | 4 + .../toggleMute/Bootstrap/AppDelegate.swift | 13 +- .../Controllers/MainController.swift | 26 +- .../Controllers/TouchBarController.swift | 46 ++-- .../Support/AudioInputController.swift | 255 ++++++++++++++++++ 5 files changed, 297 insertions(+), 47 deletions(-) create mode 100644 toggleMute/toggleMute/Support/AudioInputController.swift diff --git a/toggleMute/toggleMute.xcodeproj/project.pbxproj b/toggleMute/toggleMute.xcodeproj/project.pbxproj index 87361e4..0d1d6a5 100644 --- a/toggleMute/toggleMute.xcodeproj/project.pbxproj +++ b/toggleMute/toggleMute.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ BD391AE9263C3F5600848049 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = BD391AE8263C3F5600848049 /* LaunchAtLogin */; }; C3C528C02DF0162800BBC393 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = C3C528BF2DF0162800BBC393 /* KeyboardShortcuts */; }; C3DA000A2B88C00900B6BCCA /* EventMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DA00092B88C00900B6BCCA /* EventMonitor.swift */; }; + D9A1F4022E0099AA00112233 /* AudioInputController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9A1F4012E0099AA00112233 /* AudioInputController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -51,6 +52,7 @@ 44EB8BD723AC2ABD005A4A0B /* TouchBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBarController.swift; sourceTree = ""; }; 44EB8BDB23AC350D005A4A0B /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; C3DA00092B88C00900B6BCCA /* EventMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventMonitor.swift; sourceTree = ""; }; + D9A1F4012E0099AA00112233 /* AudioInputController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioInputController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -130,6 +132,7 @@ 44D551DF2390830E0065505A /* SharedFileList.h */, 44D551DE2390830E0065505A /* SharedFileList.m */, 449F398223173003008A0DBD /* Preferences.swift */, + D9A1F4012E0099AA00112233 /* AudioInputController.swift */, 44D551DD2390830E0065505A /* SettingsController-Bridging-Header.h */, 44EB8BD623AC28E3005A4A0B /* NSTouchBar-Private.h */, ); @@ -274,6 +277,7 @@ 449F398D23173610008A0DBD /* SettingsController.swift in Sources */, 449F398323173003008A0DBD /* Preferences.swift in Sources */, C3DA000A2B88C00900B6BCCA /* EventMonitor.swift in Sources */, + D9A1F4022E0099AA00112233 /* AudioInputController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/toggleMute/toggleMute/Bootstrap/AppDelegate.swift b/toggleMute/toggleMute/Bootstrap/AppDelegate.swift index d69e47c..3be8b8c 100644 --- a/toggleMute/toggleMute/Bootstrap/AppDelegate.swift +++ b/toggleMute/toggleMute/Bootstrap/AppDelegate.swift @@ -199,12 +199,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele @objc func runTimedCode(){ mainController.getCurrentVolume() - - if(defaults.integer(forKey: "currentSetVolume") < 5) { - touchBarController.toggleMuteStateHard(setMute: true) - } else { - touchBarController.toggleMuteStateHard(setMute: false) - } + + // Sync UI to the actual device state so the icon reflects external mute + // changes (e.g. from the system menu). Reads the real CoreAudio mute + // property — falls back to "volume == 0" only when no mute property is + // exposed. If we can't tell, leave the UI alone instead of flipping it. + guard let muted = AudioInputController.isMuted() else { return } + touchBarController.toggleMuteStateHard(setMute: muted) } diff --git a/toggleMute/toggleMute/Controllers/MainController.swift b/toggleMute/toggleMute/Controllers/MainController.swift index 5039741..59039b5 100644 --- a/toggleMute/toggleMute/Controllers/MainController.swift +++ b/toggleMute/toggleMute/Controllers/MainController.swift @@ -103,23 +103,17 @@ class MainController: NSViewController, UNUserNotificationCenterDelegate { func getCurrentVolume() { - - let setInputVolume = "return input volume of (get volume settings)" - var error: NSDictionary? - - if let scriptObject = NSAppleScript(source: setInputVolume) { - - if let outputString = scriptObject.executeAndReturnError(&error).stringValue { - - currentSetVolume = Int(outputString)! - defaults.set(currentSetVolume, forKey: "currentSetVolume") - - } else if (error != nil) { - // no error handling currently as it just works always :D - } - + // AppleScript's `input volume of (get volume settings)` is unreliable on + // Aggregate Devices (often returns a stale 100). Read the actual input + // volume via CoreAudio. If muted, report 0 so the slider/UI reflects it. + if AudioInputController.isMuted() == true { + currentSetVolume = 0 + } else if let v = AudioInputController.volume() { + currentSetVolume = Int((v * 100).rounded()) + } else { + return } - + defaults.set(currentSetVolume, forKey: "currentSetVolume") } diff --git a/toggleMute/toggleMute/Controllers/TouchBarController.swift b/toggleMute/toggleMute/Controllers/TouchBarController.swift index c146df1..b2c41de 100644 --- a/toggleMute/toggleMute/Controllers/TouchBarController.swift +++ b/toggleMute/toggleMute/Controllers/TouchBarController.swift @@ -74,18 +74,12 @@ class TouchBarController { func setNewVolume(newValue: Int) { - - let setInputAndResetOutputVolume = - """ - set volume input volume \(newValue) - set currentVol to output volume of (get volume settings) - set volume output volume currentVol - """ - var error: NSDictionary? - if let scriptObject = NSAppleScript(source: setInputAndResetOutputVolume) { - scriptObject.executeAndReturnError(&error) - } - + // Drives the *gain* of the default input device (used when restoring the + // user's preferred input volume on unmute, and when the slider moves). + // AppleScript's `set volume input volume` is a no-op on Aggregate Devices, + // so go through CoreAudio. + let clamped = max(0, min(100, newValue)) + AudioInputController.setVolume(Float32(clamped) / 100.0) } @@ -107,41 +101,43 @@ class TouchBarController { redMenuBarIcon = defaults.bool(forKey: "redMenuBarIcon") if(!setMute && isMuted){ - + defaults.set(false, forKey: "isMuted") - + button?.image = imageUnmute?.tint(color: .alternateSelectedControlTextColor) - + button?.layer?.backgroundColor = CGColor(red: 0, green: 0, blue: 0 , alpha: 0) touchBarButton?.image = imageUnmute touchBarButton?.bezelColor = NSColor.clear + + AudioInputController.setMuted(false) + var unmuteVal = 80 - if(isKeyPresentInUserDefaults(key: "defaultInputVol")){ unmuteVal = defaults.integer(forKey: "defaultInputVol") } - setNewVolume(newValue: unmuteVal) - + } else if(setMute && !isMuted) { - + defaults.set(true, forKey: "isMuted") - + button?.image = imageMute?.tint(color: .selectedMenuItemTextColor) button?.layer?.backgroundColor = CGColor(red: 0, green: 0, blue: 0 , alpha: 0) - + touchBarButton?.image = imageMute touchBarButton?.bezelColor = NSColor.red - setNewVolume(newValue: 0) - + + AudioInputController.setMuted(true) + if(redMenuBarIcon){ button?.image = imageMute?.tint(color: .red) } - + if(redMenuBarIconBackground){ button?.layer?.backgroundColor = CGColor(red: 1.0, green: 0, blue: 0 , alpha: 1.0) } - + } } diff --git a/toggleMute/toggleMute/Support/AudioInputController.swift b/toggleMute/toggleMute/Support/AudioInputController.swift new file mode 100644 index 0000000..80a391f --- /dev/null +++ b/toggleMute/toggleMute/Support/AudioInputController.swift @@ -0,0 +1,255 @@ +import Foundation +import CoreAudio + +/// CoreAudio-backed control for the default input device. +/// Handles Aggregate Devices by dispatching mute/volume to their input sub-devices, +/// since AppleScript `set volume input volume` is a no-op on most aggregates. +enum AudioInputController { + + // MARK: - Public API + + static func setMuted(_ muted: Bool) { + guard let deviceID = defaultInputDeviceID() else { return } + applyMute(deviceID: deviceID, muted: muted) + } + + static func isMuted() -> Bool? { + guard let deviceID = defaultInputDeviceID() else { return nil } + return readMute(deviceID: deviceID) + } + + /// `volume` is 0.0 ... 1.0 + static func setVolume(_ volume: Float32) { + guard let deviceID = defaultInputDeviceID() else { return } + applyVolume(deviceID: deviceID, volume: volume) + } + + /// Returns 0.0 ... 1.0, or nil if no readable volume property is present. + static func volume() -> Float32? { + guard let deviceID = defaultInputDeviceID() else { return nil } + return readVolume(deviceID: deviceID) + } + + // MARK: - Device lookup + + private static func defaultInputDeviceID() -> AudioDeviceID? { + var id: AudioDeviceID = kAudioObjectUnknown + var size = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &id + ) + guard status == noErr, id != kAudioObjectUnknown else { return nil } + return id + } + + private static func subDeviceIDs(of aggregate: AudioDeviceID) -> [AudioDeviceID] { + var address = AudioObjectPropertyAddress( + mSelector: kAudioAggregateDevicePropertyActiveSubDeviceList, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + guard AudioObjectHasProperty(aggregate, &address) else { return [] } + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(aggregate, &address, 0, nil, &size) == noErr, + size > 0 else { return [] } + let count = Int(size) / MemoryLayout.size + var ids = [AudioDeviceID](repeating: kAudioObjectUnknown, count: count) + guard AudioObjectGetPropertyData(aggregate, &address, 0, nil, &size, &ids) == noErr else { + return [] + } + return ids + } + + private static func hasInputStream(_ deviceID: AudioDeviceID) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreams, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + let status = AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) + return status == noErr && size > 0 + } + + private static func inputChannelCount(_ deviceID: AudioDeviceID) -> UInt32 { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) == noErr, + size > 0 else { return 0 } + let raw = UnsafeMutableRawPointer.allocate( + byteCount: Int(size), + alignment: MemoryLayout.alignment + ) + defer { raw.deallocate() } + guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, raw) == noErr else { + return 0 + } + let abl = UnsafeMutableAudioBufferListPointer( + raw.assumingMemoryBound(to: AudioBufferList.self) + ) + var total: UInt32 = 0 + for buffer in abl { total += buffer.mNumberChannels } + return total + } + + // MARK: - Mute + + private static func applyMute(deviceID: AudioDeviceID, muted: Bool) { + let subs = subDeviceIDs(of: deviceID) + let targets = subs.isEmpty ? [deviceID] : subs.filter(hasInputStream) + for target in targets { + if !setMuteProperty(target, muted: muted) { + // Fallback when the device exposes no writable mute: drive volume to 0/1. + _ = setVolumeProperty(target, volume: muted ? 0 : 1) + } + } + } + + private static func setMuteProperty(_ deviceID: AudioDeviceID, muted: Bool) -> Bool { + if writeMute(deviceID, channel: kAudioObjectPropertyElementMain, muted: muted) { + return true + } + let channels = inputChannelCount(deviceID) + guard channels > 0 else { return false } + var anyOK = false + for ch in 1...channels { + if writeMute(deviceID, channel: ch, muted: muted) { anyOK = true } + } + return anyOK + } + + private static func writeMute(_ deviceID: AudioDeviceID, channel: UInt32, muted: Bool) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyMute, + mScope: kAudioDevicePropertyScopeInput, + mElement: channel + ) + guard AudioObjectHasProperty(deviceID, &address) else { return false } + var settable: DarwinBoolean = false + guard AudioObjectIsPropertySettable(deviceID, &address, &settable) == noErr, + settable.boolValue else { return false } + var value: UInt32 = muted ? 1 : 0 + let size = UInt32(MemoryLayout.size) + return AudioObjectSetPropertyData(deviceID, &address, 0, nil, size, &value) == noErr + } + + private static func readMute(deviceID: AudioDeviceID) -> Bool? { + let subs = subDeviceIDs(of: deviceID) + let targets = subs.isEmpty ? [deviceID] : subs.filter(hasInputStream) + for target in targets { + if let m = readMuteOnDevice(target) { return m } + } + // Fallback: infer mute from volume == 0 + for target in targets { + if let v = readVolumeOnDevice(target) { return v < 0.05 } + } + return nil + } + + private static func readMuteOnDevice(_ deviceID: AudioDeviceID) -> Bool? { + if let v = queryMute(deviceID, channel: kAudioObjectPropertyElementMain) { return v } + let channels = inputChannelCount(deviceID) + guard channels > 0 else { return nil } + for ch in 1...channels { + if let v = queryMute(deviceID, channel: ch) { return v } + } + return nil + } + + private static func queryMute(_ deviceID: AudioDeviceID, channel: UInt32) -> Bool? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyMute, + mScope: kAudioDevicePropertyScopeInput, + mElement: channel + ) + guard AudioObjectHasProperty(deviceID, &address) else { return nil } + var value: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &value) == noErr else { + return nil + } + return value != 0 + } + + // MARK: - Volume + + private static func applyVolume(deviceID: AudioDeviceID, volume: Float32) { + let clamped = max(0, min(1, volume)) + let subs = subDeviceIDs(of: deviceID) + let targets = subs.isEmpty ? [deviceID] : subs.filter(hasInputStream) + for target in targets { + _ = setVolumeProperty(target, volume: clamped) + } + } + + private static func setVolumeProperty(_ deviceID: AudioDeviceID, volume: Float32) -> Bool { + if writeVolume(deviceID, channel: kAudioObjectPropertyElementMain, volume: volume) { + return true + } + let channels = inputChannelCount(deviceID) + guard channels > 0 else { return false } + var anyOK = false + for ch in 1...channels { + if writeVolume(deviceID, channel: ch, volume: volume) { anyOK = true } + } + return anyOK + } + + private static func writeVolume(_ deviceID: AudioDeviceID, channel: UInt32, volume: Float32) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyVolumeScalar, + mScope: kAudioDevicePropertyScopeInput, + mElement: channel + ) + guard AudioObjectHasProperty(deviceID, &address) else { return false } + var settable: DarwinBoolean = false + guard AudioObjectIsPropertySettable(deviceID, &address, &settable) == noErr, + settable.boolValue else { return false } + var value = volume + let size = UInt32(MemoryLayout.size) + return AudioObjectSetPropertyData(deviceID, &address, 0, nil, size, &value) == noErr + } + + private static func readVolume(deviceID: AudioDeviceID) -> Float32? { + let subs = subDeviceIDs(of: deviceID) + let targets = subs.isEmpty ? [deviceID] : subs.filter(hasInputStream) + for target in targets { + if let v = readVolumeOnDevice(target) { return v } + } + return nil + } + + private static func readVolumeOnDevice(_ deviceID: AudioDeviceID) -> Float32? { + if let v = queryVolume(deviceID, channel: kAudioObjectPropertyElementMain) { return v } + let channels = inputChannelCount(deviceID) + guard channels > 0 else { return nil } + for ch in 1...channels { + if let v = queryVolume(deviceID, channel: ch) { return v } + } + return nil + } + + private static func queryVolume(_ deviceID: AudioDeviceID, channel: UInt32) -> Float32? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyVolumeScalar, + mScope: kAudioDevicePropertyScopeInput, + mElement: channel + ) + guard AudioObjectHasProperty(deviceID, &address) else { return nil } + var value: Float32 = 0 + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &value) == noErr else { + return nil + } + return value + } +}