Skip to content

Commit 18204ce

Browse files
clem-gitclaude
andcommitted
fix(audio): route follows-default apps when highest-priority output reconnects
When a Bluetooth output device reconnects, macOS auto-switches the system default to it within milliseconds. By the time handleDeviceConnected runs, the connected device already equals the current default, so the previous branching matched neither case — it neither re-evaluated the default nor restored the prior one. lastConfirmedDefaultUID stayed stale on the old device and the follows-default app taps were never re-pointed; a subsequent handleDefaultDeviceChanged then reverted routing to the stale device, leaving the system default on the newly connected device while app audio kept playing on the previous one (e.g. built-in speakers). Resolve the connect decision through a pure connectedOutputDefaultAction(...): when the connected device is the highest-priority *connected* device, ensure it is the default and re-route follows-default apps via reEvaluateOutputDefault (which sets the default only when it differs and always re-points the taps). Lower-priority-hijack protection (.restorePrevious) is unchanged. Add OutputDeviceReconnectRoutingTests covering the reconnect desync, the disconnected-#1 priority resolution (WH-1000XM5 off / Buds4 on), and the preserved protection behaviour. Full FineTuneTests target passes. Addresses #255 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 50a846e commit 18204ce

2 files changed

Lines changed: 161 additions & 6 deletions

File tree

FineTune/Audio/Engine/AudioEngine.swift

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,34 @@ final class AudioEngine {
169169
}
170170
}
171171

172+
/// What an output device's connection should do to the system default and to
173+
/// follows-default app routing. Pure decision so it can be tested in isolation.
174+
enum ConnectedOutputDefaultAction: Equatable {
175+
/// Connected device is the highest-priority *connected* device — ensure it is the
176+
/// system default and re-route follows-default app taps to it. Covers both "not yet
177+
/// default" and "macOS already auto-switched here", since reEvaluateOutputDefault sets
178+
/// the default only when it differs and always re-points the taps.
179+
case ensureHighestPriorityDefault
180+
/// A lower-priority device that macOS auto-switched to — restore the device the user was on.
181+
case restorePrevious
182+
/// Lower-priority device that isn't the current default — leave routing as-is.
183+
case none
184+
}
185+
186+
static func connectedOutputDefaultAction(
187+
connectedDeviceUID: String,
188+
highestPriorityConnectedUID: String?,
189+
currentDefaultUID: String?
190+
) -> ConnectedOutputDefaultAction {
191+
if connectedDeviceUID == highestPriorityConnectedUID {
192+
return .ensureHighestPriorityDefault
193+
}
194+
if connectedDeviceUID == currentDefaultUID {
195+
return .restorePrevious
196+
}
197+
return .none
198+
}
199+
172200

173201
init(
174202
permission: AudioRecordingPermission? = nil,
@@ -1513,25 +1541,36 @@ final class AudioEngine {
15131541
// the user chose it. We still enter PENDING_AUTOSWITCH to guard against macOS
15141542
// auto-switching to the new device.
15151543
let currentDefault = deviceVolumeMonitor.defaultDeviceUID
1516-
let isNewDeviceHigherPriority = (deviceUID == Self.resolveHighestPriority(
1544+
let highestPriorityUID = Self.resolveHighestPriority(
15171545
priorityOrder: settingsManager.devicePriorityOrder,
15181546
connectedDevices: outputDevices,
15191547
isAlive: isAliveCheck
1520-
)?.uid)
1548+
)?.uid
15211549

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

1528-
if isNewDeviceHigherPriority, deviceUID != currentDefault {
1529-
// A higher-priority device reconnected — switch to it
1556+
switch Self.connectedOutputDefaultAction(
1557+
connectedDeviceUID: deviceUID,
1558+
highestPriorityConnectedUID: highestPriorityUID,
1559+
currentDefaultUID: currentDefault
1560+
) {
1561+
case .ensureHighestPriorityDefault:
1562+
// Highest-priority connected device (re)connected. Ensure it is the system default
1563+
// and route follows-default apps to it. reEvaluateOutputDefault sets the default
1564+
// only if it differs (a no-op when macOS already auto-switched here) and always
1565+
// re-points follows-default taps — fixing the BT-reconnect desync where the system
1566+
// default moved to the new device but app audio stayed stranded on the previous one.
15301567
reEvaluateOutputDefault()
1531-
} else if !isNewDeviceHigherPriority, currentDefault == deviceUID {
1532-
// macOS already auto-switched to the lower-priority device — restore
1568+
case .restorePrevious:
1569+
// macOS already auto-switched to a lower-priority device — restore
15331570
// what the user was on (not highest priority — they may have chosen a mid-priority device)
15341571
restoreConfirmedDefault()
1572+
case .none:
1573+
break
15351574
}
15361575

15371576
// Cancel any existing PENDING_AUTOSWITCH before entering a new one.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// FineTuneTests/OutputDeviceReconnectRoutingTests.swift
2+
import Testing
3+
import Foundation
4+
@testable import FineTune
5+
6+
/// Regression coverage for the Bluetooth-reconnect routing desync:
7+
/// when a headset reconnects and macOS auto-switches the system default to it,
8+
/// the highest-priority connected device must be ensured as the default so
9+
/// follows-default app taps are re-routed to it — not left stranded on the
10+
/// previous device.
11+
@Suite("Connected output default action")
12+
@MainActor
13+
struct OutputDeviceReconnectRoutingTests {
14+
15+
// The bug: headset is highest connected priority AND macOS already made it the
16+
// default. The old code did nothing here, leaving app taps on the old device.
17+
@Test("Highest-priority device already made default by macOS → ensure default + re-route apps")
18+
func highestPriorityAlreadyDefault() {
19+
let action = AudioEngine.connectedOutputDefaultAction(
20+
connectedDeviceUID: "buds",
21+
highestPriorityConnectedUID: "buds",
22+
currentDefaultUID: "buds"
23+
)
24+
#expect(action == .ensureHighestPriorityDefault)
25+
}
26+
27+
@Test("Highest-priority device reconnects while default is still the old device → ensure default")
28+
func highestPriorityNotYetDefault() {
29+
let action = AudioEngine.connectedOutputDefaultAction(
30+
connectedDeviceUID: "buds",
31+
highestPriorityConnectedUID: "buds",
32+
currentDefaultUID: "speakers"
33+
)
34+
#expect(action == .ensureHighestPriorityDefault)
35+
}
36+
37+
@Test("Highest-priority device connects with no default set yet (nil) → ensure default")
38+
func highestPriorityWithNoCurrentDefault() {
39+
let action = AudioEngine.connectedOutputDefaultAction(
40+
connectedDeviceUID: "buds",
41+
highestPriorityConnectedUID: "buds",
42+
currentDefaultUID: nil
43+
)
44+
#expect(action == .ensureHighestPriorityDefault)
45+
}
46+
47+
// The user's real setup: WH-1000XM5 is #1 in the priority list but disconnected,
48+
// Buds4 (#2) and built-in speakers (#3) are connected. The disconnected #1 must be
49+
// skipped so the highest *connected* device (the Buds) is what reconnect logic acts on.
50+
@Test("resolveHighestPriority skips a disconnected #1 and returns the highest connected device")
51+
func highestPrioritySkipsDisconnectedTopDevice() {
52+
let buds = AudioDevice(id: 2, uid: "buds", name: "Buds4 Pro", icon: nil, supportsAutoEQ: false)
53+
let speakers = AudioDevice(id: 3, uid: "speakers", name: "MacBook Pro Speakers", icon: nil, supportsAutoEQ: false)
54+
55+
// wh1000xm5 (#1) is disconnected → absent from the connected set entirely.
56+
let resolved = AudioEngine.resolveHighestPriority(
57+
priorityOrder: ["wh1000xm5", "buds", "speakers"],
58+
connectedDevices: [buds, speakers],
59+
isAlive: { _ in true }
60+
)
61+
#expect(resolved?.uid == "buds")
62+
63+
// …and that resolved device, once macOS makes it default, must be ensured.
64+
let action = AudioEngine.connectedOutputDefaultAction(
65+
connectedDeviceUID: "buds",
66+
highestPriorityConnectedUID: resolved?.uid,
67+
currentDefaultUID: "buds"
68+
)
69+
#expect(action == .ensureHighestPriorityDefault)
70+
}
71+
72+
// Protection behaviour must be preserved: a *lower*-priority device that macOS
73+
// hijacked the default to should be reverted to the device the user was on.
74+
@Test("Lower-priority device that macOS auto-switched to → restore previous")
75+
func lowerPriorityHijackRestores() {
76+
let action = AudioEngine.connectedOutputDefaultAction(
77+
connectedDeviceUID: "airpods",
78+
highestPriorityConnectedUID: "wh1000xm5",
79+
currentDefaultUID: "airpods"
80+
)
81+
#expect(action == .restorePrevious)
82+
}
83+
84+
@Test("Lower-priority device connects but default unchanged → do nothing")
85+
func lowerPriorityNonDefaultIsNoop() {
86+
let action = AudioEngine.connectedOutputDefaultAction(
87+
connectedDeviceUID: "airpods",
88+
highestPriorityConnectedUID: "wh1000xm5",
89+
currentDefaultUID: "wh1000xm5"
90+
)
91+
#expect(action == .none)
92+
}
93+
94+
@Test("No connected devices resolved (nil highest) and not default → do nothing")
95+
func nilHighestNonDefaultIsNoop() {
96+
let action = AudioEngine.connectedOutputDefaultAction(
97+
connectedDeviceUID: "buds",
98+
highestPriorityConnectedUID: nil,
99+
currentDefaultUID: "speakers"
100+
)
101+
#expect(action == .none)
102+
}
103+
104+
// Defensive: a live connected device normally makes resolveHighestPriority non-nil
105+
// (its fallback returns any alive device), so nil-highest-while-default shouldn't occur
106+
// in practice — but pin the behaviour so a future refactor can't silently change it.
107+
@Test("Nil highest but device is current default → restore previous")
108+
func nilHighestButIsDefaultRestores() {
109+
let action = AudioEngine.connectedOutputDefaultAction(
110+
connectedDeviceUID: "buds",
111+
highestPriorityConnectedUID: nil,
112+
currentDefaultUID: "buds"
113+
)
114+
#expect(action == .restorePrevious)
115+
}
116+
}

0 commit comments

Comments
 (0)