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
4 changes: 4 additions & 0 deletions AudioPriorityBar.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
A10000000000000000000005 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* MenuBarView.swift */; };
A10000000000000000000006 /* DeviceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* DeviceListView.swift */; };
A10000000000000000000007 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* LaunchAtLoginManager.swift */; };
A10000000000000000000021 /* WirelessLinkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000021 /* WirelessLinkMonitor.swift */; };
A10000000000000000000009 /* Headphones.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* Headphones.swift */; };
A10000000000000000000010 /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A20000000000000000000010 /* CoreAudio.framework */; };
A10000000000000000000020 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000020 /* Assets.xcassets */; };
Expand All @@ -28,6 +29,7 @@
A20000000000000000000006 /* DeviceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceListView.swift; sourceTree = "<group>"; };
A20000000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A20000000000000000000008 /* LaunchAtLoginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginManager.swift; sourceTree = "<group>"; };
A20000000000000000000021 /* WirelessLinkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WirelessLinkMonitor.swift; sourceTree = "<group>"; };
A20000000000000000000009 /* Headphones.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Headphones.swift; sourceTree = "<group>"; };
A20000000000000000000010 /* CoreAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreAudio.framework; path = System/Library/Frameworks/CoreAudio.framework; sourceTree = SDKROOT; };
A20000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
Expand Down Expand Up @@ -83,6 +85,7 @@
A20000000000000000000003 /* AudioDeviceService.swift */,
A20000000000000000000004 /* PriorityManager.swift */,
A20000000000000000000008 /* LaunchAtLoginManager.swift */,
A20000000000000000000021 /* WirelessLinkMonitor.swift */,
);
path = Services;
sourceTree = "<group>";
Expand Down Expand Up @@ -189,6 +192,7 @@
A10000000000000000000005 /* MenuBarView.swift in Sources */,
A10000000000000000000006 /* DeviceListView.swift in Sources */,
A10000000000000000000007 /* LaunchAtLoginManager.swift in Sources */,
A10000000000000000000021 /* WirelessLinkMonitor.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
27 changes: 25 additions & 2 deletions AudioPriorityBar/AudioPriorityBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class AudioManager: ObservableObject {
@Published var micFlashState: Bool = false

private let deviceService = AudioDeviceService()
private let linkMonitor = WirelessLinkMonitor()
private var micFlashTimer: Timer?
let priorityManager = PriorityManager()
private var connectedDeviceUIDs: Set<String> = []
Expand Down Expand Up @@ -167,12 +168,27 @@ class AudioManager: ObservableObject {
refreshMuteStatus()
setupDeviceChangeListener()
setupMuteVolumeListener()
setupLinkMonitor()
if !isCustomMode {
applyHighestPriorityInput()
applyHighestPriorityOutput()
}
}

private func setupLinkMonitor() {
linkMonitor.onLinkChange = { [weak self] in
Task { @MainActor in
guard let self else { return }
self.refreshDevices()
if !self.isCustomMode {
self.applyHighestPriorityInput()
self.applyHighestPriorityOutput()
}
}
}
linkMonitor.start()
}

private func setupMuteVolumeListener() {
deviceService.onMuteOrVolumeChanged = { [weak self] in
Task { @MainActor in
Expand Down Expand Up @@ -253,6 +269,13 @@ class AudioManager: ObservableObject {
connectedDeviceUIDs.contains(device.uid)
}

/// A dongle that is plugged in but whose headset is not linked to it: present in
/// CoreAudio, yet unable to carry audio, so it is skipped when auto-selecting.
/// A device with no known link signal is never reported as down.
func isLinkDown(_ device: AudioDevice) -> Bool {
device.isConnected && !linkMonitor.isUsable(deviceNamed: device.name)
}

/// Tracks device UIDs from the previous refresh to detect new connections
private var previousConnectedUIDs: Set<String> = []

Expand Down Expand Up @@ -396,14 +419,14 @@ class AudioManager: ObservableObject {
}

private func applyHighestPriorityInput() {
if let first = inputDevices.first(where: { $0.isConnected && !priorityManager.isNeverUse($0) }) {
if let first = inputDevices.first(where: { $0.isConnected && !priorityManager.isNeverUse($0) && !isLinkDown($0) }) {
applyInputDevice(first)
}
}

private func applyHighestPriorityOutput() {
let devices = activeOutputDevices
if let first = devices.first(where: { $0.isConnected && !priorityManager.isNeverUse($0) }) {
if let first = devices.first(where: { $0.isConnected && !priorityManager.isNeverUse($0) && !isLinkDown($0) }) {
applyOutputDevice(first)
}
refreshMuteStatus()
Expand Down
203 changes: 203 additions & 0 deletions AudioPriorityBar/Services/WirelessLinkMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import Foundation
import IOKit
import IOKit.hid

/// Reports whether a wireless headset is actually linked to its USB dongle.
///
/// A dongle publishes its CoreAudio devices whenever it is plugged in, whether or not
/// the headset it pairs with is powered on. Nothing in CoreAudio separates the two
/// states — measured on a Jabra Link 390 with the headset off and then on, the driver
/// never publishes `kAudioDevicePropertyJackIsConnected`, and `DeviceIsAlive`, channel
/// counts, available sample rates, stream formats and `CanBeDefaultDevice` are all
/// identical. Priority selection therefore picks the dongle and sends audio into a
/// dead link: it shows as Active while nothing comes out.
///
/// Some dongles do report the truth over HID. This monitor reads that bit for the
/// dongles it knows about.
///
/// **A device this monitor does not recognise is always reported as usable**, so
/// behaviour is unchanged for every other piece of hardware.
final class WirelessLinkMonitor {

/// A dongle whose HID interface exposes a headset-link bit.
struct DongleProfile {
let vendorID: Int
/// Matched against both the HID product string and the CoreAudio device name.
let nameFragment: String
let reportID: UInt8
let byteIndex: Int
let bitMask: UInt8
}

/// Jabra Link 390. HID Telephony usage 0x2A ("Line"), report 0x04 byte 1 bit 3.
/// It reads 1 only while the headset is linked to the dongle.
///
/// Verified against the hardware: powering the headset on over Bluetooth left the
/// bit at 0 while the headset's Bluetooth devices appeared, and when the headset
/// later moved its link to the dongle the bit went to 1 and the Bluetooth devices
/// were removed 1.7 seconds later.
static let knownDongles: [DongleProfile] = [
DongleProfile(vendorID: 0x0B0E,
nameFragment: "Jabra Link 390",
reportID: 0x04,
byteIndex: 1,
bitMask: 0x08)
]

enum LinkState {
case up // headset is linked to the dongle
case down // dongle present, headset not linked
case unknown // no matching dongle, or the bit could not be read
}

/// How long the link must stay down before the device is treated as unusable.
/// Rides out a momentary drop so a brief glitch cannot pull audio away mid-call.
private static let downDebounce: TimeInterval = 3.0

/// Called when a link goes up or down, on the main queue.
var onLinkChange: (() -> Void)?

private let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
private var attached: [IOHIDDevice: DongleProfile] = [:]
private var buffers: [UnsafeMutablePointer<UInt8>] = []
private var states: [String: LinkState] = [:] // keyed by nameFragment
private var downSince: [String: Date] = [:]
private var pollTimer: Timer?

// MARK: - Lifecycle

func start() {
let criteria = Self.knownDongles.map { [kIOHIDVendorIDKey: $0.vendorID] }
IOHIDManagerSetDeviceMatchingMultiple(manager, criteria as CFArray)

let context = Unmanaged.passUnretained(self).toOpaque()
IOHIDManagerRegisterDeviceMatchingCallback(manager, { context, _, _, device in
guard let context else { return }
Unmanaged<WirelessLinkMonitor>.fromOpaque(context).takeUnretainedValue().attach(device)
}, context)
IOHIDManagerRegisterDeviceRemovalCallback(manager, { context, _, _, device in
guard let context else { return }
Unmanaged<WirelessLinkMonitor>.fromOpaque(context).takeUnretainedValue().detach(device)
}, context)

IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))

if let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice> {
for device in devices { attach(device) }
}

// Backstop for a missed report, which would otherwise leave a device gated
// until the next time the headset is toggled.
pollTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
self?.poll()
}
}

deinit {
pollTimer?.invalidate()
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
for buffer in buffers { buffer.deallocate() }
}

// MARK: - Queries

/// Link state for a CoreAudio device, matched by name.
func linkState(forDeviceNamed name: String) -> LinkState {
guard let profile = Self.knownDongles.first(where: { name.contains($0.nameFragment) }) else {
return .unknown
}
return states[profile.nameFragment] ?? .unknown
}

/// Whether a device may be auto-selected. Only a settled `down` blocks it; anything
/// unrecognised or unreadable stays selectable.
func isUsable(deviceNamed name: String) -> Bool {
guard case .down = linkState(forDeviceNamed: name) else { return true }
guard let profile = Self.knownDongles.first(where: { name.contains($0.nameFragment) }),
let since = downSince[profile.nameFragment] else { return true }
return Date().timeIntervalSince(since) < Self.downDebounce
}

// MARK: - HID

private func profile(for device: IOHIDDevice) -> DongleProfile? {
guard let product = IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString) as? String else {
return nil
}
return Self.knownDongles.first { product.contains($0.nameFragment) }
}

/// The matching callback also fires for devices already connected at startup, so
/// without this guard the explicit enumeration would attach each device twice.
private func attach(_ device: IOHIDDevice) {
guard attached[device] == nil, let profile = profile(for: device) else { return }
attached[device] = profile

IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeNone))
let size = (IOHIDDeviceGetProperty(device, kIOHIDMaxInputReportSizeKey as CFString) as? Int) ?? 64
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: size)
buffers.append(buffer)

let context = Unmanaged.passUnretained(self).toOpaque()
IOHIDDeviceRegisterInputReportCallback(device, buffer, size, { context, _, sender, _, reportID, report, length in
guard let context, let sender else { return }
let monitor = Unmanaged<WirelessLinkMonitor>.fromOpaque(context).takeUnretainedValue()
let device = Unmanaged<IOHIDDevice>.fromOpaque(sender).takeUnretainedValue()
guard let profile = monitor.attached[device],
UInt8(reportID) == profile.reportID,
Int(length) > profile.byteIndex else { return }
monitor.update(profile: profile, isLinked: (report[profile.byteIndex] & profile.bitMask) != 0)
}, context)
IOHIDDeviceScheduleWithRunLoop(device, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)

if let isLinked = read(device, profile) {
// A link already down at startup gets no grace period: the debounce is for
// riding out a drop during use.
states[profile.nameFragment] = isLinked ? .up : .down
downSince[profile.nameFragment] = isLinked ? nil : .distantPast
}
onLinkChange?()
}

private func detach(_ device: IOHIDDevice) {
guard let profile = attached.removeValue(forKey: device) else { return }
states[profile.nameFragment] = .unknown
downSince[profile.nameFragment] = nil
onLinkChange?()
}

private func read(_ device: IOHIDDevice, _ profile: DongleProfile) -> Bool? {
var buffer = [UInt8](repeating: 0, count: 64)
var length = CFIndex(buffer.count)
let result = IOHIDDeviceGetReport(device, kIOHIDReportTypeInput,
CFIndex(profile.reportID), &buffer, &length)
guard result == kIOReturnSuccess, Int(length) > profile.byteIndex else { return nil }
return (buffer[profile.byteIndex] & profile.bitMask) != 0
}

private func poll() {
for (device, profile) in attached {
if let isLinked = read(device, profile) {
update(profile: profile, isLinked: isLinked)
}
}
}

private func update(profile: DongleProfile, isLinked: Bool) {
let newState: LinkState = isLinked ? .up : .down
guard states[profile.nameFragment] != newState else { return }
states[profile.nameFragment] = newState

if isLinked {
downSince[profile.nameFragment] = nil
} else {
downSince[profile.nameFragment] = Date()
// No further HID report will arrive, so re-evaluate once the debounce ends.
DispatchQueue.main.asyncAfter(deadline: .now() + Self.downDebounce + 0.1) { [weak self] in
self?.onLinkChange?()
}
}
onLinkChange?()
}
}
20 changes: 19 additions & 1 deletion AudioPriorityBar/Views/DeviceListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,13 @@ struct DraggableDeviceRow: View {
}

var isGrayed: Bool {
isDisconnected || isHiddenSection
isDisconnected || isHiddenSection || isLinkDown
}

/// Plugged in, but the wireless headset is not linked to it, so it cannot
/// carry audio and is skipped when picking the highest priority device.
var isLinkDown: Bool {
audioManager.isLinkDown(device)
}

var isNeverUse: Bool {
Expand All @@ -124,6 +130,8 @@ struct DraggableDeviceRow: View {
var statusIcon: String? {
if isDisconnected {
return "wifi.slash"
} else if isLinkDown {
return "antenna.radiowaves.left.and.right.slash"
} else if isIgnored && audioManager.isEditMode {
return "eye.slash"
} else if isNeverUse {
Expand All @@ -140,6 +148,10 @@ struct DraggableDeviceRow: View {
return stored.lastSeenRelative
}

var linkStatusText: String? {
isLinkDown ? "not linked" : nil
}

var isMuted: Bool {
device.isConnected && audioManager.isDeviceMuted(device)
}
Expand Down Expand Up @@ -209,6 +221,12 @@ struct DraggableDeviceRow: View {
.foregroundColor(.secondary.opacity(0.6))
}

if let linkStatus = linkStatusText {
Text(linkStatus)
.font(.system(size: 10))
.foregroundColor(.secondary.opacity(0.6))
}

if isMuted {
Text("Muted")
.font(.system(size: 9, weight: .semibold))
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ A native macOS menu bar app that automatically manages audio device priorities.
- **Separate speaker/headphone modes**: Output devices are categorized as either speakers or headphones, each with their own priority list.
- **Manual override**: Enable "Custom" mode (hand icon) to disable auto-switching and select devices freely.
- **Device memory**: Remembers all devices you've ever connected, even when disconnected. Edit mode shows disconnected devices with "last seen" timestamps.
- **Wireless link awareness**: A USB headset dongle publishes its audio devices whenever it is plugged in, even when the headset is off. Recognised dongles are skipped while no headset is linked to them, so audio is not sent into a dead link. Devices without a known link signal are unaffected.
- **Per-category ignore**: Hide devices from specific categories without affecting others.
- **Drag-to-reorder**: Reorder devices by dragging or using up/down arrows.
- **Volume control**: Adjust volume with slider or scroll wheel.
Expand Down Expand Up @@ -85,6 +86,7 @@ Click "Edit" in the footer to:
2. **Priority Storage**: Device priorities are stored in UserDefaults, keyed by device UID (stable across reconnects).
3. **Auto-Switching**: When devices connect/disconnect, the app automatically selects the highest-priority available device for the current mode.
4. **Categories**: Each output device is assigned to either "speaker" or "headphone" category, with separate priority lists.
5. **Link Checking**: For dongles listed in `WirelessLinkMonitor.knownDongles`, an HID bit reports whether the headset is actually linked. CoreAudio cannot tell: it reports the dongle as alive with the same channels, sample rates and stream formats either way. A device with no known link signal is always treated as usable.

## Project Structure

Expand All @@ -95,7 +97,8 @@ AudioPriorityBar/
│ └── AudioDevice.swift # Device model, OutputCategory enum
├── Services/
│ ├── AudioDeviceService.swift # CoreAudio wrapper
│ └── PriorityManager.swift # Priority persistence
│ ├── PriorityManager.swift # Priority persistence
│ └── WirelessLinkMonitor.swift # HID headset-link detection for USB dongles
└── Views/
├── MenuBarView.swift # Main popover UI
└── DeviceListView.swift # Device list and row components
Expand Down