Skip to content
Open
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
180 changes: 153 additions & 27 deletions HiFidelity/Core/Audio/DACManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,29 @@ struct AudioOutputDevice: Identifiable, Equatable {
/// Hog mode gives the application exclusive access to the audio device
class DACManager: ObservableObject {
static let shared = DACManager()

@Published private(set) var currentDeviceID: AudioDeviceID = 0
@Published private(set) var systemDefaultDeviceID: AudioDeviceID = 0
@Published private(set) var availableDevices: [AudioOutputDevice] = []
@Published private(set) var currentDevice: AudioOutputDevice?
@Published private(set) var systemDefaultDevice: AudioOutputDevice?
@Published private(set) var deviceWasRemoved: Bool = false

@Published private(set) var followsSystemDefault: Bool = true

private var isHogging = false
private var deviceListListenerProc: AudioObjectPropertyListenerProc?

private var defaultDeviceListenerProc: AudioObjectPropertyListenerProc?
private init() {
currentDeviceID = getDefaultOutputDevice()
systemDefaultDeviceID = currentDeviceID
refreshDeviceList()
setupDeviceChangeListener()
setupDefaultDeviceChangeListener()
}

deinit {
removeDeviceChangeListener()
nonisolated deinit {
Task { @MainActor [weak self] in
self?.removeDeviceChangeListener()
self?.removeDefaultDeviceChangeListener()
}
Comment on lines +48 to +52
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new nonisolated deinit schedules listener removal asynchronously and captures self weakly. By the time the Task runs, self will often already be deallocated, so the listeners never get removed and CoreAudio callbacks can later dereference a freed clientData pointer (use-after-free / crash). Also, nonisolated is only valid for actor-isolated/global-actor-isolated types; if DACManager isn’t @MainActor, this likely won’t compile. Remove both listeners synchronously in deinit (no Task, no weak self), and if you truly need main-actor isolation, mark the type @MainActor and keep teardown synchronous within deinit.

Suggested change
nonisolated deinit {
Task { @MainActor [weak self] in
self?.removeDeviceChangeListener()
self?.removeDefaultDeviceChangeListener()
}
deinit {
removeDeviceChangeListener()
removeDefaultDeviceChangeListener()

Copilot uses AI. Check for mistakes.
}

// MARK: - Device Management
Expand Down Expand Up @@ -393,7 +399,37 @@ extension DACManager {
selfPtr
)
}


/// Set up listener for system default output device changes
private func setupDefaultDeviceChangeListener() {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)

let listenerProc: AudioObjectPropertyListenerProc = { _, _, _, clientData in
guard let clientData = clientData else { return noErr }
let manager = Unmanaged<DACManager>.fromOpaque(clientData).takeUnretainedValue()

DispatchQueue.main.async {
manager.handleDefaultOutputDeviceChanged()
}

return noErr
}

defaultDeviceListenerProc = listenerProc

let selfPtr = Unmanaged.passUnretained(self).toOpaque()

AudioObjectAddPropertyListener(
AudioObjectID(kAudioObjectSystemObject),
&address,
listenerProc,
selfPtr
)
}
/// Remove device change listener
private func removeDeviceChangeListener() {
guard let listenerProc = deviceListListenerProc else { return }
Expand All @@ -413,7 +449,46 @@ extension DACManager {
selfPtr
)
}


/// Remove default output device change listener
private func removeDefaultDeviceChangeListener() {
guard let listenerProc = defaultDeviceListenerProc else { return }

var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)

let selfPtr = Unmanaged.passUnretained(self).toOpaque()

AudioObjectRemovePropertyListener(
AudioObjectID(kAudioObjectSystemObject),
&address,
listenerProc,
selfPtr
)
}

/// Handle system default output device changes
private func handleDefaultOutputDeviceChanged() {
let defaultID = getDefaultOutputDevice()
Logger.info("System default output device changed: \(defaultID)")

availableDevices = getAllOutputDevices()
updateSystemDefaultDevice()

guard followsSystemDefault else {
return
}

guard let defaultDevice = systemDefaultDevice, defaultID != 0 else {
Logger.warning("System default device unavailable after change")
return
}

_ = performDeviceSwitch(defaultDevice)
}
/// Handle device list changes (hot-plugging)
private func handleDeviceListChanged() {
Logger.info("Audio device list changed (device added/removed)")
Expand Down Expand Up @@ -494,21 +569,7 @@ extension DACManager {
let defaultDeviceID = getDefaultOutputDevice()

if defaultDeviceID != 0 {
// Build device info for the new default device
if let deviceName = getDeviceNameForID(defaultDeviceID),
let deviceUID = getDeviceUIDForID(defaultDeviceID) {

let sampleRate = getCurrentSampleRateForDevice(defaultDeviceID)
let channels = getChannelCountForDevice(defaultDeviceID)

let newDevice = AudioOutputDevice(
id: defaultDeviceID,
name: deviceName,
uid: deviceUID,
sampleRate: sampleRate,
channels: channels
)

if let newDevice = buildDeviceInfo(for: defaultDeviceID) {
Logger.info("Auto-switching to default device: \(newDevice.name)")
if wasHogging {
Logger.info("Sample rate synchronization was disabled - re-enable manually if needed")
Expand Down Expand Up @@ -885,11 +946,14 @@ extension DACManager {
/// Refresh the list of available devices
func refreshDeviceList() {
availableDevices = getAllOutputDevices()
updateSystemDefaultDevice()
currentDevice = availableDevices.first { $0.id == currentDeviceID }

// Only update to system default if we're in an invalid state (device is 0 or doesn't exist)
// Don't follow system default changes - preserve user's device selection
if currentDeviceID == 0 {
if followsSystemDefault {
if systemDefaultDeviceID != 0 {
currentDeviceID = systemDefaultDeviceID
currentDevice = systemDefaultDevice
}
} else if currentDeviceID == 0 {
let defaultID = getDefaultOutputDevice()
if defaultID != 0 {
Logger.debug("No device selected, using system default: \(defaultID)")
Expand All @@ -907,6 +971,35 @@ extension DACManager {

/// Switch to a different audio device
func switchToDevice(_ device: AudioOutputDevice) -> Bool {
followsSystemDefault = false
return performDeviceSwitch(device)
}

/// Switch to the current system default output device
func switchToSystemDefault() -> Bool {
let previousDeviceID = currentDeviceID
followsSystemDefault = true

availableDevices = getAllOutputDevices()
updateSystemDefaultDevice()
currentDevice = availableDevices.first { $0.id == currentDeviceID }

guard let defaultDevice = systemDefaultDevice else {
Logger.warning("System default device not available")
return false
}

if defaultDevice.id == previousDeviceID {
currentDeviceID = defaultDevice.id
currentDevice = defaultDevice
return true
}

return performDeviceSwitch(defaultDevice)
}

/// Internal device switch without changing follow-default mode
private func performDeviceSwitch(_ device: AudioOutputDevice) -> Bool {
guard device.id != currentDeviceID else {
Logger.debug("Already using device: \(device.name)")
return true
Expand Down Expand Up @@ -942,5 +1035,38 @@ extension DACManager {

return true
}

/// Build device info for a specific ID
private func buildDeviceInfo(for deviceID: AudioDeviceID) -> AudioOutputDevice? {
guard let deviceName = getDeviceNameForID(deviceID),
let deviceUID = getDeviceUIDForID(deviceID)
else {
return nil
}

let sampleRate = getCurrentSampleRateForDevice(deviceID)
let channels = getChannelCountForDevice(deviceID)

return AudioOutputDevice(
id: deviceID,
name: deviceName,
uid: deviceUID,
sampleRate: sampleRate,
channels: channels
)
}

/// Update cached system default device info
private func updateSystemDefaultDevice() {
let defaultID = getDefaultOutputDevice()
systemDefaultDeviceID = defaultID
if let matchedDevice = availableDevices.first(where: { $0.id == defaultID }) {
systemDefaultDevice = matchedDevice
} else if defaultID != 0 {
systemDefaultDevice = buildDeviceInfo(for: defaultID)
} else {
systemDefaultDevice = nil
}
}
}

88 changes: 85 additions & 3 deletions HiFidelity/Views/Playback/AudioDeviceSelector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ struct AudioDeviceSelector: View {
// Device list
ScrollView {
VStack(alignment: .leading, spacing: 0) {
systemDefaultRow

Divider()

if dacManager.availableDevices.isEmpty {
Text("No output devices found")
.font(.system(size: 12))
Expand Down Expand Up @@ -90,7 +94,74 @@ struct AudioDeviceSelector: View {
}

// MARK: - Device Row

private var systemDefaultRow: some View {
Button(action: {
selectSystemDefault()
}) {
HStack(spacing: 12) {
Image(systemName: isSystemDefaultSelected ? "checkmark.circle.fill" : "circle")
.font(AppFonts.labelLarge)
.foregroundColor(
isSystemDefaultSelected
? theme.currentTheme.primaryColor : .secondary.opacity(0.3)
)
.frame(width: 20)

VStack(alignment: .leading, spacing: 2) {
Text("System Default")
.font(AppFonts.bodySmall)
.foregroundColor(.primary)

if let defaultDevice = dacManager.systemDefaultDevice {
HStack(spacing: 6) {
Text(defaultDevice.name)
.font(AppFonts.captionMedium)
.foregroundColor(.secondary)

/*
Text("•")
.font(AppFonts.captionMedium)
.foregroundColor(.secondary.opacity(0.5))

Text("\(Int(defaultDevice.sampleRate)) Hz")
.font(AppFonts.captionMedium)
.foregroundColor(.secondary)

Text("•")
.font(AppFonts.captionMedium)
.foregroundColor(.secondary.opacity(0.5))

Text(channelDescription(defaultDevice.channels))
.font(AppFonts.captionMedium)
.foregroundColor(.secondary)
*/
Comment on lines +120 to +137
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s a large commented-out UI block inside the System Default row (sample rate / channels). Leaving dead UI code in-line makes this view harder to maintain; either remove it or reintroduce it behind a clear condition/flag so the intent is preserved without commented code.

Suggested change
/*
Text("")
.font(AppFonts.captionMedium)
.foregroundColor(.secondary.opacity(0.5))
Text("\(Int(defaultDevice.sampleRate)) Hz")
.font(AppFonts.captionMedium)
.foregroundColor(.secondary)
Text("")
.font(AppFonts.captionMedium)
.foregroundColor(.secondary.opacity(0.5))
Text(channelDescription(defaultDevice.channels))
.font(AppFonts.captionMedium)
.foregroundColor(.secondary)
*/

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if the "System Default" row should display technical details (sample rate + channel configuration)

}
} else {
Text("No default device available")
.font(AppFonts.captionMedium)
.foregroundColor(.secondary)
}
}

Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
.background(
isSystemDefaultSelected
? theme.currentTheme.primaryColor.opacity(0.1) : Color.clear
)
.onHover { hovering in
if hovering && !isSystemDefaultSelected {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
}
private func deviceRow(_ device: AudioOutputDevice) -> some View {
Button(action: {
selectDevice(device)
Expand Down Expand Up @@ -155,9 +226,20 @@ struct AudioDeviceSelector: View {

}
}

private func selectSystemDefault() {
guard !isSystemDefaultSelected else { return }

if dacManager.switchToSystemDefault() {
Logger.info("Switched to system default output")
showDeviceMenu = false
}
}
private func isCurrentDevice(_ device: AudioOutputDevice) -> Bool {
device.id == dacManager.currentDeviceID
!dacManager.followsSystemDefault && device.id == dacManager.currentDeviceID
}

private var isSystemDefaultSelected: Bool {
dacManager.followsSystemDefault
}

private func channelDescription(_ channels: Int) -> String {
Expand Down
Loading