From 30296e76b796a8bc8ffb813c43124a7ff63e6043 Mon Sep 17 00:00:00 2001 From: Serhiy Zabolotnyy Date: Thu, 6 Aug 2026 14:32:06 +0300 Subject: [PATCH] Use native mute across input devices --- MicStatusAI/Errors/MicrophoneError.swift | 6 + MicStatusAI/Generated/L10n.swift | 10 ++ .../MicrophoneVolumeControlling.swift | 7 +- .../Models/MicrophoneStatusModel.swift | 65 ++++++++-- MicStatusAI/Resources/Localizable.xcstrings | 30 ++++- .../Services/CoreAudioMicrophone.swift | 112 ++++++++++++++++-- README.md | 11 +- 7 files changed, 209 insertions(+), 32 deletions(-) diff --git a/MicStatusAI/Errors/MicrophoneError.swift b/MicStatusAI/Errors/MicrophoneError.swift index 14d4a8c..8f66791 100644 --- a/MicStatusAI/Errors/MicrophoneError.swift +++ b/MicStatusAI/Errors/MicrophoneError.swift @@ -3,6 +3,8 @@ import Foundation enum MicrophoneError: LocalizedError { case noDefaultInputDevice case volumeControlUnavailable + case muteControlUnavailable + case muteStateVerificationFailed case coreAudio(OSStatus) var errorDescription: String? { @@ -11,6 +13,10 @@ enum MicrophoneError: LocalizedError { L10n.errorNoMicrophone case .volumeControlUnavailable: L10n.errorVolumeUnavailable + case .muteControlUnavailable: + L10n.errorMuteUnavailable + case .muteStateVerificationFailed: + L10n.errorMuteVerificationFailed case let .coreAudio(status): L10n.errorCoreAudio(Int(status)) } diff --git a/MicStatusAI/Generated/L10n.swift b/MicStatusAI/Generated/L10n.swift index 98daf59..d1d472d 100644 --- a/MicStatusAI/Generated/L10n.swift +++ b/MicStatusAI/Generated/L10n.swift @@ -50,6 +50,16 @@ public enum L10n { return tr(key: "error.invalidHotkey") } + /// Default microphone does not expose input mute control. + public static var errorMuteUnavailable: String { + return tr(key: "error.muteUnavailable") + } + + /// Default microphone did not confirm the requested mute state. + public static var errorMuteVerificationFailed: String { + return tr(key: "error.muteVerificationFailed") + } + /// No default microphone found. public static var errorNoMicrophone: String { return tr(key: "error.noMicrophone") diff --git a/MicStatusAI/Interfaces/MicrophoneVolumeControlling.swift b/MicStatusAI/Interfaces/MicrophoneVolumeControlling.swift index cfd554c..5f06e3a 100644 --- a/MicStatusAI/Interfaces/MicrophoneVolumeControlling.swift +++ b/MicStatusAI/Interfaces/MicrophoneVolumeControlling.swift @@ -1,4 +1,7 @@ protocol MicrophoneVolumeControlling { - func inputVolume() throws -> Float32 - func setInputVolume(_ volume: Float32) throws + func defaultInputDeviceID() throws -> UInt32 + func inputVolume(for deviceID: UInt32) throws -> Float32 + func setInputVolume(_ volume: Float32, for deviceID: UInt32) throws + func isMuted(for deviceID: UInt32) throws -> Bool + func setMuted(_ muted: Bool, for deviceID: UInt32) throws } diff --git a/MicStatusAI/Models/MicrophoneStatusModel.swift b/MicStatusAI/Models/MicrophoneStatusModel.swift index cbc1722..b9c4936 100644 --- a/MicStatusAI/Models/MicrophoneStatusModel.swift +++ b/MicStatusAI/Models/MicrophoneStatusModel.swift @@ -39,6 +39,8 @@ final class MicrophoneStatusModel { @ObservationIgnored private let hotKeyManager: any HotKeyManaging @ObservationIgnored private var pollingTimer: Timer? @ObservationIgnored private var lastNonzeroVolume: Float32 + @ObservationIgnored private var currentInputDeviceID: UInt32? + @ObservationIgnored private var desiredMuteState: MicrophoneMuteState = .indeterminate private static let hotKeyDefaultsKey = "muteHotKey" private static let lastVolumeDefaultsKey = "lastNonzeroInputVolume" @@ -104,12 +106,20 @@ final class MicrophoneStatusModel { guard canAdjustInputLevel else { return } do { + let deviceID = try microphone.defaultInputDeviceID() let clampedLevel = min(max(level, 0), 1) - try microphone.setInputVolume(Float32(clampedLevel)) + try microphone.setInputVolume(Float32(clampedLevel), for: deviceID) if clampedLevel > 0 { lastNonzeroVolume = Float32(clampedLevel) UserDefaults.standard.set(clampedLevel, forKey: Self.lastVolumeDefaultsKey) } + + let shouldMute = clampedLevel == 0 + if try microphone.isMuted(for: deviceID) != shouldMute { + try microphone.setMuted(shouldMute, for: deviceID) + } + currentInputDeviceID = deviceID + desiredMuteState = shouldMute ? .muted : .active refreshStatus() } catch { status = .unavailable(error.localizedDescription) @@ -118,15 +128,17 @@ final class MicrophoneStatusModel { func toggleMute() { do { - let currentVolume = try microphone.inputVolume() - if currentVolume > 0 { - lastNonzeroVolume = currentVolume - UserDefaults.standard.set(Double(currentVolume), forKey: Self.lastVolumeDefaultsKey) - try microphone.setInputVolume(0) - } else { - try microphone.setInputVolume(max(lastNonzeroVolume, 0.01)) + let deviceID = try microphone.defaultInputDeviceID() + let currentlyMuted = try intendedMuteState(for: deviceID) + if currentlyMuted, try microphone.inputVolume(for: deviceID) == 0 { + try microphone.setInputVolume(max(lastNonzeroVolume, 0.01), for: deviceID) } + let targetMuteState = !currentlyMuted + try microphone.setMuted(targetMuteState, for: deviceID) + currentInputDeviceID = deviceID + desiredMuteState = targetMuteState ? .muted : .active + if isMonitoring { refreshStatus() } @@ -145,20 +157,49 @@ final class MicrophoneStatusModel { guard isMonitoring else { return } do { - let volume = try microphone.inputVolume() + let deviceID = try microphone.defaultInputDeviceID() + let deviceChanged = currentInputDeviceID.map { $0 != deviceID } ?? false + if deviceChanged { + try applyDesiredMuteState(to: deviceID) + } + + let volume = try microphone.inputVolume(for: deviceID) + let muted = try microphone.isMuted(for: deviceID) + currentInputDeviceID = deviceID + desiredMuteState = muted ? .muted : .active inputLevel = Double(volume) if volume > 0 { lastNonzeroVolume = volume UserDefaults.standard.set(Double(volume), forKey: Self.lastVolumeDefaultsKey) - status = .active(volume) - } else { - status = .muted } + status = muted ? .muted : .active(volume) } catch { status = .unavailable(error.localizedDescription) } } + private func intendedMuteState(for deviceID: UInt32) throws -> Bool { + switch desiredMuteState { + case .active: + false + case .muted: + true + case .indeterminate: + try microphone.isMuted(for: deviceID) + } + } + + private func applyDesiredMuteState(to deviceID: UInt32) throws { + switch desiredMuteState { + case .active: + try microphone.setMuted(false, for: deviceID) + case .muted: + try microphone.setMuted(true, for: deviceID) + case .indeterminate: + break + } + } + private func saveHotKey() { guard let data = try? JSONEncoder().encode(hotKey) else { return } UserDefaults.standard.set(data, forKey: Self.hotKeyDefaultsKey) diff --git a/MicStatusAI/Resources/Localizable.xcstrings b/MicStatusAI/Resources/Localizable.xcstrings index 72b8d12..449d4c3 100644 --- a/MicStatusAI/Resources/Localizable.xcstrings +++ b/MicStatusAI/Resources/Localizable.xcstrings @@ -2,7 +2,7 @@ "sourceLanguage" : "en", "strings" : { "action.mute" : { - "comment" : "Button that sets microphone input volume to zero.", + "comment" : "Button that enables the default microphone's CoreAudio input mute property.", "extractionState" : "manual", "localizations" : { "en" : { @@ -62,7 +62,7 @@ } }, "action.unmute" : { - "comment" : "Button that restores microphone input volume.", + "comment" : "Button that disables the default microphone's CoreAudio input mute property.", "extractionState" : "manual", "localizations" : { "en" : { @@ -109,6 +109,30 @@ } } }, + "error.muteUnavailable" : { + "comment" : "Error shown when the default microphone has no writable CoreAudio input mute property.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default microphone does not expose input mute control." + } + } + } + }, + "error.muteVerificationFailed" : { + "comment" : "Error shown when the default microphone does not confirm a requested mute state.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default microphone did not confirm the requested mute state." + } + } + } + }, "error.noMicrophone" : { "comment" : "Error shown when macOS has no default microphone.", "extractionState" : "manual", @@ -458,7 +482,7 @@ } }, "status.muted" : { - "comment" : "Primary status shown when microphone input volume is zero.", + "comment" : "Primary status shown when the CoreAudio input mute property is enabled.", "extractionState" : "manual", "localizations" : { "en" : { diff --git a/MicStatusAI/Services/CoreAudioMicrophone.swift b/MicStatusAI/Services/CoreAudioMicrophone.swift index a4c5d75..63b35c7 100644 --- a/MicStatusAI/Services/CoreAudioMicrophone.swift +++ b/MicStatusAI/Services/CoreAudioMicrophone.swift @@ -1,8 +1,7 @@ import CoreAudio struct CoreAudioMicrophone: MicrophoneVolumeControlling { - func inputVolume() throws -> Float32 { - let deviceID = try defaultInputDeviceID() + func inputVolume(for deviceID: UInt32) throws -> Float32 { let addresses = volumeAddresses(for: deviceID) guard !addresses.isEmpty else { throw MicrophoneError.volumeControlUnavailable @@ -12,8 +11,7 @@ struct CoreAudioMicrophone: MicrophoneVolumeControlling { return volumes.max() ?? 0 } - func setInputVolume(_ volume: Float32) throws { - let deviceID = try defaultInputDeviceID() + func setInputVolume(_ volume: Float32, for deviceID: UInt32) throws { let clampedVolume = min(max(volume, 0), 1) let addresses = writableVolumeAddresses(for: deviceID) guard !addresses.isEmpty else { @@ -36,7 +34,47 @@ struct CoreAudioMicrophone: MicrophoneVolumeControlling { } } - private func defaultInputDeviceID() throws -> AudioDeviceID { + func isMuted(for deviceID: UInt32) throws -> Bool { + let addresses = muteAddresses(for: deviceID) + guard !addresses.isEmpty else { + throw MicrophoneError.muteControlUnavailable + } + + return try addresses.allSatisfy { + try readMute(deviceID: deviceID, address: $0) + } + } + + func setMuted(_ muted: Bool, for deviceID: UInt32) throws { + let addresses = writableMuteAddresses(for: deviceID) + guard !addresses.isEmpty else { + throw MicrophoneError.muteControlUnavailable + } + + for var address in addresses { + var value: UInt32 = muted ? 1 : 0 + let status = AudioObjectSetPropertyData( + deviceID, + &address, + 0, + nil, + UInt32(MemoryLayout.size), + &value + ) + guard status == noErr else { + throw MicrophoneError.coreAudio(status) + } + } + + let verified = try addresses.allSatisfy { + try readMute(deviceID: deviceID, address: $0) == muted + } + guard verified else { + throw MicrophoneError.muteStateVerificationFailed + } + } + + func defaultInputDeviceID() throws -> UInt32 { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, @@ -75,11 +113,26 @@ struct CoreAudioMicrophone: MicrophoneVolumeControlling { } private func writableVolumeAddresses(for deviceID: AudioDeviceID) -> [AudioObjectPropertyAddress] { - volumeAddresses(for: deviceID).filter { address in - var mutableAddress = address - var isSettable = DarwinBoolean(false) - let status = AudioObjectIsPropertySettable(deviceID, &mutableAddress, &isSettable) - return status == noErr && isSettable.boolValue + volumeAddresses(for: deviceID).filter { + isPropertySettable(deviceID: deviceID, address: $0) + } + } + + private func muteAddresses(for deviceID: AudioDeviceID) -> [AudioObjectPropertyAddress] { + let mainElementAddress = makeMuteAddress(element: kAudioObjectPropertyElementMain) + if hasProperty(deviceID: deviceID, address: mainElementAddress) { + return [mainElementAddress] + } + + return (1 ... 32).compactMap { channel in + let address = makeMuteAddress(element: AudioObjectPropertyElement(channel)) + return hasProperty(deviceID: deviceID, address: address) ? address : nil + } + } + + private func writableMuteAddresses(for deviceID: AudioDeviceID) -> [AudioObjectPropertyAddress] { + muteAddresses(for: deviceID).filter { + isPropertySettable(deviceID: deviceID, address: $0) } } @@ -91,6 +144,14 @@ struct CoreAudioMicrophone: MicrophoneVolumeControlling { ) } + private func makeMuteAddress(element: AudioObjectPropertyElement) -> AudioObjectPropertyAddress { + AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyMute, + mScope: kAudioDevicePropertyScopeInput, + mElement: element + ) + } + private func hasProperty( deviceID: AudioDeviceID, address: AudioObjectPropertyAddress @@ -99,6 +160,16 @@ struct CoreAudioMicrophone: MicrophoneVolumeControlling { return AudioObjectHasProperty(deviceID, &mutableAddress) } + private func isPropertySettable( + deviceID: AudioDeviceID, + address: AudioObjectPropertyAddress + ) -> Bool { + var mutableAddress = address + var isSettable = DarwinBoolean(false) + let status = AudioObjectIsPropertySettable(deviceID, &mutableAddress, &isSettable) + return status == noErr && isSettable.boolValue + } + private func readVolume( deviceID: AudioDeviceID, address: AudioObjectPropertyAddress @@ -119,4 +190,25 @@ struct CoreAudioMicrophone: MicrophoneVolumeControlling { } return volume } + + private func readMute( + deviceID: AudioDeviceID, + address: AudioObjectPropertyAddress + ) throws -> Bool { + var mutableAddress = address + var muted: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData( + deviceID, + &mutableAddress, + 0, + nil, + &size, + &muted + ) + guard status == noErr else { + throw MicrophoneError.coreAudio(status) + } + return muted != 0 + } } diff --git a/README.md b/README.md index 86b4042..24e752d 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ [![Pull Request Build](https://github.com/Disconnecter/MicStatusAI/actions/workflows/pr-build.yml/badge.svg)](https://github.com/Disconnecter/MicStatusAI/actions/workflows/pr-build.yml) -Native macOS 15+ menu-bar utility showing default microphone input-volume state. +Native macOS 15+ menu-bar utility showing default microphone mute state and input volume. -- **Green mic:** input volume is greater than zero -- **Red muted mic:** input volume is zero +- **Green mic:** CoreAudio input mute is disabled +- **Red muted mic:** CoreAudio input mute is enabled and verified - **Gray off mic:** monitoring is stopped - **Global hotkey:** Control-Option-M by default; configurable in Settings +- **Device switching:** current mute state follows newly selected default microphone -MicStatusAI reads and changes CoreAudio input-volume properties. It does not capture or record audio and therefore does not request microphone-recording permission. +MicStatusAI reads and changes CoreAudio input-mute and input-volume properties. Mute writes are read back for verification. It does not capture or record audio and therefore does not request microphone-recording permission. ## Screenshots @@ -75,7 +76,7 @@ Add short keys and translations to `MicStatusAI/Resources/Localizable.xcstrings` See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and pull request workflow. -Some microphones, including certain digital or virtual devices, do not expose software input-volume controls. MicStatusAI reports those devices as unavailable. +Some microphones, including certain digital or virtual devices, do not expose software input-mute or input-volume controls. MicStatusAI reports those devices as unavailable. ## License