Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions MicStatusAI/Errors/MicrophoneError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import Foundation
enum MicrophoneError: LocalizedError {
case noDefaultInputDevice
case volumeControlUnavailable
case muteControlUnavailable
case muteStateVerificationFailed
case coreAudio(OSStatus)

var errorDescription: String? {
Expand All @@ -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))
}
Expand Down
10 changes: 10 additions & 0 deletions MicStatusAI/Generated/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
7 changes: 5 additions & 2 deletions MicStatusAI/Interfaces/MicrophoneVolumeControlling.swift
Original file line number Diff line number Diff line change
@@ -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
}
65 changes: 53 additions & 12 deletions MicStatusAI/Models/MicrophoneStatusModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
Expand All @@ -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)
Expand Down
30 changes: 27 additions & 3 deletions MicStatusAI/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" : {
Expand Down
112 changes: 102 additions & 10 deletions MicStatusAI/Services/CoreAudioMicrophone.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -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<UInt32>.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,
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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<UInt32>.size)
let status = AudioObjectGetPropertyData(
deviceID,
&mutableAddress,
0,
nil,
&size,
&muted
)
guard status == noErr else {
throw MicrophoneError.coreAudio(status)
}
return muted != 0
}
}
Loading