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
92 changes: 90 additions & 2 deletions Sources/AudioOutputDevice.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,105 @@
import Foundation
import CoreAudio

// =============================================================================
// MARK: - Audio Device Transport Type
// =============================================================================

/// Hardware transport mechanism used to connect the audio output device.
enum AudioDeviceTransportType: String, Equatable, CaseIterable, Sendable {
case builtIn = "Built-in"
case bluetooth = "Bluetooth"
case usb = "USB"
case hdmi = "HDMI"
case displayPort = "DisplayPort"
case airPlay = "AirPlay"
case thunderbolt = "Thunderbolt"
case pci = "PCI"
case fireWire = "FireWire"
case virtual = "Virtual"
case unknown = "Audio Device"

/// Maps CoreAudio 4-character transport codes (`kAudioDevicePropertyTransportType`) to typed enum.
static func from(fourCC: UInt32) -> AudioDeviceTransportType {
switch fourCC {
case kAudioDeviceTransportTypeBuiltIn:
return .builtIn
case kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE:
return .bluetooth
case kAudioDeviceTransportTypeUSB:
return .usb
case kAudioDeviceTransportTypeHDMI:
return .hdmi
case kAudioDeviceTransportTypeDisplayPort:
return .displayPort
case kAudioDeviceTransportTypeAirPlay:
return .airPlay
case kAudioDeviceTransportTypeThunderbolt:
return .thunderbolt
case kAudioDeviceTransportTypePCI:
return .pci
case kAudioDeviceTransportTypeFireWire:
return .fireWire
case kAudioDeviceTransportTypeVirtual, kAudioDeviceTransportTypeAggregate, kAudioDeviceTransportTypeAutoAggregate:
return .virtual
default:
return .unknown
}
}
}

// =============================================================================
// MARK: - Audio Output Device Model
// =============================================================================

/// Encapsulates a hardware audio output device (e.g. MacBook Speakers, AirPods, External DAC).
struct AudioOutputDevice: Identifiable, Hashable, Equatable {
struct AudioOutputDevice: Identifiable, Hashable, Equatable, Sendable {
/// CoreAudio AudioDeviceID (HAL integer identifier).
let id: AudioDeviceID
/// User-friendly name (e.g. "MacBook Pro Speakers", "AirPods Pro").
let name: String
/// Unique persistent string identifier (e.g. "BuiltInSpeakerDevice", Bluetooth address).
/// Unique persistent string identifier (e.g. "BuiltInSpeakerDevice", Bluetooth MAC address).
let uid: String
/// Hardware transport bus type.
let transportType: AudioDeviceTransportType

init(id: AudioDeviceID, name: String, uid: String, transportType: AudioDeviceTransportType = .unknown) {
self.id = id
self.name = name
self.uid = uid
self.transportType = transportType
}

/// Resolves an appropriate SF Symbol icon name based on transport type and device name.
var iconName: String {
let lower = name.lowercased()
if lower.contains("airpod max") {
return "headphones"
} else if lower.contains("airpod") {
return "airpodspro"
} else if lower.contains("beats") || lower.contains("headphone") || lower.contains("headset") || lower.contains("earphone") {
return "headphones"
} else if lower.contains("homepod") {
return "homepod.fill"
} else if lower.contains("hdmi") || lower.contains("tv") || lower.contains("television") {
return "tv"
} else if lower.contains("studio display") || lower.contains("pro display") || lower.contains("displayport") || lower.contains("monitor") {
return "display"
} else if transportType == .bluetooth {
return "beats.headphones"
} else if transportType == .usb {
return "hifispeaker.fill"
} else if transportType == .builtIn {
if lower.contains("imac") || lower.contains("mac pro") || lower.contains("mac mini") || lower.contains("desktop") {
return "desktopcomputer"
}
return "laptopcomputer"
} else if transportType == .airPlay {
return "airplayaudio"
} else {
return "speaker.wave.2.fill"
}
}

static func == (lhs: AudioOutputDevice, rhs: AudioOutputDevice) -> Bool {
lhs.id == rhs.id && lhs.uid == rhs.uid
Expand Down
63 changes: 60 additions & 3 deletions Sources/AudioTapManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ class AudioTapManager: NSObject, ObservableObject {
/// List of all detected output-capable audio devices on the system.
@Published var availableOutputDevices: [AudioOutputDevice] = []

/// UserDefaults key for storing user's preferred output device UID.
private let preferredOutputDeviceUIDKey = "MySoundPreferredOutputDeviceUID"

/// Tracks known device UIDs between HAL callbacks to distinguish reconnected devices from existing devices.
private var previousKnownDeviceUIDs: Set<String> = []

/// Dynamically resolved pointer to private `responsibility_get_pid_responsible_for_pid` symbol in libproc.
/// This allows mapping sandboxed helper processes (like Chrome Helper or Safari WebContent) to their parent app.
private nonisolated static let getResponsiblePID: (@convention(c) (pid_t) -> pid_t)? = {
Expand Down Expand Up @@ -263,10 +269,46 @@ class AudioTapManager: NSObject, ObservableObject {
/// Refreshes available output devices and recreates taps if the active output device changed.
func refreshOutputDevices() {
let devices = getAvailableOutputDevices()
let current = getDefaultOutputDevice()
let currentUIDs = Set(devices.map { $0.uid })
let newlyConnectedUIDs = previousKnownDeviceUIDs.isEmpty ? [] : currentUIDs.subtracting(previousKnownDeviceUIDs)
previousKnownDeviceUIDs = currentUIDs

var current = getDefaultOutputDevice()

// Auto-restore preferred device ONLY if it was disconnected and just reconnected
if let preferredUID = UserDefaults.standard.string(forKey: preferredOutputDeviceUIDKey),
newlyConnectedUIDs.contains(preferredUID),
let preferredDevice = devices.first(where: { $0.uid == preferredUID }),
current?.uid != preferredUID {
var devID = preferredDevice.id
let propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
if AudioObjectSetPropertyData(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, propertySize, &devID) == noErr {
current = preferredDevice
AppLogger.shared.log("Auto-restored newly reconnected preferred output device: \(preferredDevice.name) (\(preferredUID))")
}
}

let deviceChanged = self.currentOutputDevice?.uid != current?.uid
self.availableOutputDevices = devices
self.currentOutputDevice = current

// If the current device is present, record it as user's active preference
// (unless the preferred device is merely disconnected)
if let currentUID = current?.uid {
if let preferredUID = UserDefaults.standard.string(forKey: preferredOutputDeviceUIDKey) {
if currentUIDs.contains(preferredUID) {
UserDefaults.standard.set(currentUID, forKey: preferredOutputDeviceUIDKey)
}
} else {
UserDefaults.standard.set(currentUID, forKey: preferredOutputDeviceUIDKey)
}
}

if deviceChanged && current != nil {
// Re-bind all active taps to the new physical output device
recreateAllTaps()
Expand Down Expand Up @@ -688,7 +730,18 @@ class AudioTapManager: NSObject, ObservableObject {
deviceUID = uidStr
}

outputDevices.append(AudioOutputDevice(id: deviceID, name: deviceName, uid: deviceUID))
// Get transport type
var transportAddr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyTransportType,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var transportTypeFourCC: UInt32 = 0
var transportSize = UInt32(MemoryLayout<UInt32>.size)
let transportStatus = AudioObjectGetPropertyData(deviceID, &transportAddr, 0, nil, &transportSize, &transportTypeFourCC)
let transportType: AudioDeviceTransportType = (transportStatus == noErr) ? .from(fourCC: transportTypeFourCC) : .unknown

outputDevices.append(AudioOutputDevice(id: deviceID, name: deviceName, uid: deviceUID, transportType: transportType))
}

return outputDevices
Expand Down Expand Up @@ -723,8 +776,12 @@ class AudioTapManager: NSObject, ObservableObject {
let status = AudioObjectSetPropertyData(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, propertySize, &devID)
if status == noErr {
self.currentOutputDevice = device
UserDefaults.standard.set(device.uid, forKey: preferredOutputDeviceUIDKey)
AppLogger.shared.log("Switched default output device to \(device.name) (\(device.uid))")
// Re-bind all active taps to the new physical output device
recreateAllTaps()
} else {
print("MySound: Failed to set default output device \(device.name) (status: \(status))")
AppLogger.shared.log("Failed to set default output device \(device.name) (status: \(status))")
}
}

Expand Down
71 changes: 32 additions & 39 deletions Sources/VolumeControlView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,43 +278,52 @@ struct VolumeControlView: View {
// MARK: Header & Master Volume
// -----------------------------------------------------------------
VStack(alignment: .leading, spacing: 8) {
HStack {
HStack(alignment: .center) {
// Output device selection dropdown
if tapManager.availableOutputDevices.count > 1 {
Menu {
Menu {
Section(header: Text("Switch Output Device")) {
ForEach(tapManager.availableOutputDevices) { device in
Button(action: {
tapManager.setDefaultOutputDevice(device)
}) {
HStack {
Text(device.name)
Label {
Text(device.name)
} icon: {
Image(systemName: device.iconName)
}
if tapManager.currentOutputDevice?.id == device.id {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Text(tapManager.currentOutputDevice?.name ?? "Output Device")
.font(.headline)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
} label: {
HStack(spacing: 5) {
Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.blue)

Text(tapManager.currentOutputDevice?.name ?? "Output Device")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)

if tapManager.availableOutputDevices.count > 1 {
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.font(.system(size: 9, weight: .bold))
.foregroundColor(.secondary)
}
}
.menuStyle(.borderlessButton)
.accessibilityLabel("Select Output Device, currently \(tapManager.currentOutputDevice?.name ?? "Output Device")")
} else {
Text(tapManager.currentOutputDevice?.name ?? "Output Device")
.font(.headline)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.primary.opacity(0.04))
.cornerRadius(6)
}
.menuStyle(.borderlessButton)
.accessibilityLabel("Select Output Device, currently \(tapManager.currentOutputDevice?.name ?? "Output Device")")

Spacer()

Expand All @@ -334,7 +343,7 @@ struct VolumeControlView: View {
// Master Volume Slider Row: [Device Icon] [Speaker Mute Button] [Slider] [Percentage]
HStack(spacing: 6) {
// Device Icon
Image(systemName: deviceIconName(for: tapManager.currentOutputDevice?.name))
Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer")
.font(.system(size: 13))
.foregroundColor(.secondary)
.frame(width: 18, height: 18)
Expand Down Expand Up @@ -648,25 +657,9 @@ struct VolumeControlView: View {
isLaunchAtLogin = SMAppService.mainApp.status == .enabled
}

/// Resolves an appropriate SF Symbol icon name based on the audio device name.
/// Resolves an appropriate SF Symbol icon name based on the active audio device.
private func deviceIconName(for name: String?) -> String {
guard let name = name?.lowercased() else { return "laptopcomputer" }
if name.contains("airpod") {
return "airpodspro"
} else if name.contains("headphone") || name.contains("headset") || name.contains("earphone") {
return "headphones"
} else if name.contains("hdmi") || name.contains("tv") || name.contains("displayport") || name.contains("monitor") {
return "tv"
} else if name.contains("homepod") {
return "homepod.fill"
} else if name.contains("studio") || name.contains("pro display") {
return "display"
} else if name.contains("imac") || name.contains("mac pro") || name.contains("mac mini") || name.contains("desktop") {
return "desktopcomputer"
} else {
// Default built-in / MacBook Air / MacBook Pro speakers
return "laptopcomputer"
}
tapManager.currentOutputDevice?.iconName ?? "laptopcomputer"
}

// MARK: - CoreAudio Volume/Mute Property Listeners
Expand Down
Loading