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
51 changes: 45 additions & 6 deletions FineTune/Audio/Engine/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,34 @@ final class AudioEngine {
}
}

/// What an output device's connection should do to the system default and to
/// follows-default app routing. Pure decision so it can be tested in isolation.
enum ConnectedOutputDefaultAction: Equatable {
/// Connected device is the highest-priority *connected* device — ensure it is the
/// system default and re-route follows-default app taps to it. Covers both "not yet
/// default" and "macOS already auto-switched here", since reEvaluateOutputDefault sets
/// the default only when it differs and always re-points the taps.
case ensureHighestPriorityDefault
/// A lower-priority device that macOS auto-switched to — restore the device the user was on.
case restorePrevious
/// Lower-priority device that isn't the current default — leave routing as-is.
case none
}

static func connectedOutputDefaultAction(
connectedDeviceUID: String,
highestPriorityConnectedUID: String?,
currentDefaultUID: String?
) -> ConnectedOutputDefaultAction {
if connectedDeviceUID == highestPriorityConnectedUID {
return .ensureHighestPriorityDefault
}
if connectedDeviceUID == currentDefaultUID {
return .restorePrevious
}
return .none
}


init(
permission: AudioRecordingPermission? = nil,
Expand Down Expand Up @@ -1513,25 +1541,36 @@ final class AudioEngine {
// the user chose it. We still enter PENDING_AUTOSWITCH to guard against macOS
// auto-switching to the new device.
let currentDefault = deviceVolumeMonitor.defaultDeviceUID
let isNewDeviceHigherPriority = (deviceUID == Self.resolveHighestPriority(
let highestPriorityUID = Self.resolveHighestPriority(
priorityOrder: settingsManager.devicePriorityOrder,
connectedDevices: outputDevices,
isAlive: isAliveCheck
)?.uid)
)?.uid

// If this device is present but not alive, watch for it to become alive
if let device = deviceMonitor.device(for: deviceUID),
!isAliveCheck(device.id) {
installAliveWatcher(deviceID: device.id, uid: deviceUID, name: deviceName)
}

if isNewDeviceHigherPriority, deviceUID != currentDefault {
// A higher-priority device reconnected — switch to it
switch Self.connectedOutputDefaultAction(
connectedDeviceUID: deviceUID,
highestPriorityConnectedUID: highestPriorityUID,
currentDefaultUID: currentDefault
) {
case .ensureHighestPriorityDefault:
// Highest-priority connected device (re)connected. Ensure it is the system default
// and route follows-default apps to it. reEvaluateOutputDefault sets the default
// only if it differs (a no-op when macOS already auto-switched here) and always
// re-points follows-default taps — fixing the BT-reconnect desync where the system
// default moved to the new device but app audio stayed stranded on the previous one.
reEvaluateOutputDefault()
} else if !isNewDeviceHigherPriority, currentDefault == deviceUID {
// macOS already auto-switched to the lower-priority device — restore
case .restorePrevious:
// macOS already auto-switched to a lower-priority device — restore
// what the user was on (not highest priority — they may have chosen a mid-priority device)
restoreConfirmedDefault()
case .none:
break
}

// Cancel any existing PENDING_AUTOSWITCH before entering a new one.
Expand Down
116 changes: 116 additions & 0 deletions FineTuneTests/OutputDeviceReconnectRoutingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// FineTuneTests/OutputDeviceReconnectRoutingTests.swift
import Testing
import Foundation
@testable import FineTune

/// Regression coverage for the Bluetooth-reconnect routing desync:
/// when a headset reconnects and macOS auto-switches the system default to it,
/// the highest-priority connected device must be ensured as the default so
/// follows-default app taps are re-routed to it — not left stranded on the
/// previous device.
@Suite("Connected output default action")
@MainActor
struct OutputDeviceReconnectRoutingTests {

// The bug: headset is highest connected priority AND macOS already made it the
// default. The old code did nothing here, leaving app taps on the old device.
@Test("Highest-priority device already made default by macOS → ensure default + re-route apps")
func highestPriorityAlreadyDefault() {
let action = AudioEngine.connectedOutputDefaultAction(
connectedDeviceUID: "buds",
highestPriorityConnectedUID: "buds",
currentDefaultUID: "buds"
)
#expect(action == .ensureHighestPriorityDefault)
}

@Test("Highest-priority device reconnects while default is still the old device → ensure default")
func highestPriorityNotYetDefault() {
let action = AudioEngine.connectedOutputDefaultAction(
connectedDeviceUID: "buds",
highestPriorityConnectedUID: "buds",
currentDefaultUID: "speakers"
)
#expect(action == .ensureHighestPriorityDefault)
}

@Test("Highest-priority device connects with no default set yet (nil) → ensure default")
func highestPriorityWithNoCurrentDefault() {
let action = AudioEngine.connectedOutputDefaultAction(
connectedDeviceUID: "buds",
highestPriorityConnectedUID: "buds",
currentDefaultUID: nil
)
#expect(action == .ensureHighestPriorityDefault)
}

// The user's real setup: WH-1000XM5 is #1 in the priority list but disconnected,
// Buds4 (#2) and built-in speakers (#3) are connected. The disconnected #1 must be
// skipped so the highest *connected* device (the Buds) is what reconnect logic acts on.
@Test("resolveHighestPriority skips a disconnected #1 and returns the highest connected device")
func highestPrioritySkipsDisconnectedTopDevice() {
let buds = AudioDevice(id: 2, uid: "buds", name: "Buds4 Pro", icon: nil, supportsAutoEQ: false)
let speakers = AudioDevice(id: 3, uid: "speakers", name: "MacBook Pro Speakers", icon: nil, supportsAutoEQ: false)

// wh1000xm5 (#1) is disconnected → absent from the connected set entirely.
let resolved = AudioEngine.resolveHighestPriority(
priorityOrder: ["wh1000xm5", "buds", "speakers"],
connectedDevices: [buds, speakers],
isAlive: { _ in true }
)
#expect(resolved?.uid == "buds")

// …and that resolved device, once macOS makes it default, must be ensured.
let action = AudioEngine.connectedOutputDefaultAction(
connectedDeviceUID: "buds",
highestPriorityConnectedUID: resolved?.uid,
currentDefaultUID: "buds"
)
#expect(action == .ensureHighestPriorityDefault)
}

// Protection behaviour must be preserved: a *lower*-priority device that macOS
// hijacked the default to should be reverted to the device the user was on.
@Test("Lower-priority device that macOS auto-switched to → restore previous")
func lowerPriorityHijackRestores() {
let action = AudioEngine.connectedOutputDefaultAction(
connectedDeviceUID: "airpods",
highestPriorityConnectedUID: "wh1000xm5",
currentDefaultUID: "airpods"
)
#expect(action == .restorePrevious)
}

@Test("Lower-priority device connects but default unchanged → do nothing")
func lowerPriorityNonDefaultIsNoop() {
let action = AudioEngine.connectedOutputDefaultAction(
connectedDeviceUID: "airpods",
highestPriorityConnectedUID: "wh1000xm5",
currentDefaultUID: "wh1000xm5"
)
#expect(action == .none)
}

@Test("No connected devices resolved (nil highest) and not default → do nothing")
func nilHighestNonDefaultIsNoop() {
let action = AudioEngine.connectedOutputDefaultAction(
connectedDeviceUID: "buds",
highestPriorityConnectedUID: nil,
currentDefaultUID: "speakers"
)
#expect(action == .none)
}

// Defensive: a live connected device normally makes resolveHighestPriority non-nil
// (its fallback returns any alive device), so nil-highest-while-default shouldn't occur
// in practice — but pin the behaviour so a future refactor can't silently change it.
@Test("Nil highest but device is current default → restore previous")
func nilHighestButIsDefaultRestores() {
let action = AudioEngine.connectedOutputDefaultAction(
connectedDeviceUID: "buds",
highestPriorityConnectedUID: nil,
currentDefaultUID: "buds"
)
#expect(action == .restorePrevious)
}
}