fix(ios): break audio session route-change feedback loop
handleRouteChange called applyConfiguration() unconditionally, which called setCategory/setPreferredInput/etc., which triggered another route-change notification, which called applyConfiguration() again — an infinite loop that burned CPU (phone slowdown) and repeatedly tore down/rebuilt the audio session (audio cycling on/off, VoiceOver glitching). Two fixes: 1. handleRouteChange now only re-applies config on external device changes (.oldDeviceUnavailable / .newDeviceAvailable), not on .categoryChange / .routeConfigurationChange which are triggered by our own setCategory calls. 2. IOSAudioRouter.applyConfiguration() gained a re-entrancy guard (isApplyingConfiguration) for synchronous route-change notifications. Also added os.Logger logging to both files (subsystem cat.voice.VoiceCatiOS) so future issues can be debugged from Console.app on the Mac.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import AVFoundation
|
||||
import os
|
||||
import VoiceCatCore
|
||||
|
||||
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
|
||||
|
||||
@MainActor
|
||||
final class AudioSessionManager {
|
||||
static let shared = AudioSessionManager()
|
||||
@@ -57,12 +60,47 @@ final class AudioSessionManager {
|
||||
}
|
||||
|
||||
@objc private func handleRouteChange(_ notification: Notification) {
|
||||
// Refresh the router's published state so the Settings UI updates, and re-apply
|
||||
// the stored preferences (the new route may need the preferred input re-set).
|
||||
guard let info = notification.userInfo,
|
||||
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
||||
else {
|
||||
logger.warning("routeChange — unknown reason, refreshing only")
|
||||
IOSAudioRouter.shared.refreshRoutes()
|
||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("routeChange reason=\(self.reasonLabel(reason))")
|
||||
|
||||
// Re-apply preferences ONLY on external device plug/unplug. Do NOT re-apply on
|
||||
// .categoryChange / .routeConfigurationChange — those are triggered by our own
|
||||
// applyConfiguration() calls (setCategory, setPreferredInput, etc.), and re-applying
|
||||
// would create an infinite notification loop:
|
||||
// handleRouteChange → applyConfiguration → setCategory → routeChange → ...
|
||||
// That loop burns CPU and cycles the audio session on/off — the "glitching" bug.
|
||||
// IOSAudioRouter.applyConfiguration() also has a re-entrancy guard for synchronous
|
||||
// notifications, but the reason check here is the primary defense.
|
||||
if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable {
|
||||
logger.info("routeChange — external device change, re-applying config")
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
}
|
||||
|
||||
IOSAudioRouter.shared.refreshRoutes()
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||
}
|
||||
|
||||
private func reasonLabel(_ reason: AVAudioSession.RouteChangeReason) -> String {
|
||||
switch reason {
|
||||
case .oldDeviceUnavailable: return "oldDeviceUnavailable"
|
||||
case .newDeviceAvailable: return "newDeviceAvailable"
|
||||
case .categoryChange: return "categoryChange"
|
||||
case .override: return "override"
|
||||
case .wakeFromSleep: return "wakeFromSleep"
|
||||
case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
|
||||
case .routeConfigurationChange: return "routeConfigurationChange"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
|
||||
Reference in New Issue
Block a user