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.
109 lines
4.7 KiB
Swift
109 lines
4.7 KiB
Swift
import AVFoundation
|
|
import os
|
|
import VoiceCatCore
|
|
|
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
|
|
|
|
@MainActor
|
|
final class AudioSessionManager {
|
|
static let shared = AudioSessionManager()
|
|
|
|
weak var client: VoiceCatClient?
|
|
|
|
func configure() {
|
|
// Load stored audio routing preferences and apply them before any audio session
|
|
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
|
|
// miniaudio (the core) does NOT touch AVAudioSession on iOS.
|
|
IOSAudioRouter.shared.loadStoredPreferences()
|
|
IOSAudioRouter.shared.applyConfiguration()
|
|
IOSAudioRouter.shared.refreshRoutes()
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self, selector: #selector(handleInterruption),
|
|
name: AVAudioSession.interruptionNotification, object: nil)
|
|
NotificationCenter.default.addObserver(
|
|
self, selector: #selector(handleRouteChange),
|
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
|
}
|
|
|
|
func activateForStreaming() throws {
|
|
// Re-apply the routing configuration before activating, in case the user changed
|
|
// settings since the last apply. The core (miniaudio) will open whatever route
|
|
// AVAudioSession has established.
|
|
IOSAudioRouter.shared.applyConfiguration()
|
|
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
|
}
|
|
|
|
func deactivateAfterStreaming() {
|
|
try? AVAudioSession.sharedInstance().setActive(false,
|
|
options: .notifyOthersOnDeactivation)
|
|
}
|
|
|
|
@objc private func handleInterruption(_ notification: Notification) {
|
|
guard let info = notification.userInfo,
|
|
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
|
else { return }
|
|
|
|
switch type {
|
|
case .began:
|
|
client?.audioSuspend()
|
|
case .ended:
|
|
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
|
if options.contains(.shouldResume) {
|
|
try? AVAudioSession.sharedInstance().setActive(true)
|
|
client?.audioResume()
|
|
}
|
|
@unknown default: break
|
|
}
|
|
}
|
|
|
|
@objc private func handleRouteChange(_ notification: Notification) {
|
|
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()
|
|
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 {
|
|
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
|
|
}
|