From ab973940df9315851a49499087176c9d040f42a0 Mon Sep 17 00:00:00 2001 From: Talon Date: Fri, 19 Jun 2026 13:26:23 +0200 Subject: [PATCH] fix(ios): break audio session route-change feedback loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../iOS/VoiceCatiOS/AudioSessionManager.swift | 44 +++++++++++++++++-- .../iOS/VoiceCatiOS/IOSAudioRouter.swift | 42 +++++++++++++++--- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift b/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift index 12be3c4..5317dde 100644 --- a/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift +++ b/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift @@ -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 { diff --git a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift index 63f1881..626bb79 100644 --- a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift +++ b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift @@ -1,6 +1,9 @@ import AVFoundation +import os import VoiceCatCore +private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter") + /// iOS audio routing layer — drives all iOS audio route selection via `AVAudioSession` /// *before* the core (miniaudio) opens its device. miniaudio does NOT touch /// `AVAudioSession` on iOS; it opens the current default route via CoreAudio and that's @@ -75,6 +78,13 @@ final class IOSAudioRouter: ObservableObject { private let kDataSourceId = "cat.voice.audio.dataSourceId" private let kPolarPattern = "cat.voice.audio.polarPattern" + /// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change + /// notifications synchronously on the same thread. Without this guard, + /// handleRouteChange → applyConfiguration → setCategory → route-change notification + /// → handleRouteChange → applyConfiguration → ... creates an infinite loop that + /// burns CPU and cycles the audio session on/off (the "glitching" bug). + private var isApplyingConfiguration = false + private init() {} // MARK: - Load / refresh from AVAudioSession @@ -131,7 +141,16 @@ final class IOSAudioRouter: ObservableObject { /// Apply the full audio configuration to AVAudioSession. Call this before the core /// opens its capture device (i.e. before `startMicStream` → `activateForStreaming`). + /// Re-entrant-safe: if a route-change notification fires synchronously during a + /// `setCategory`/`setPreferredInput` call, the guard prevents re-entry. func applyConfiguration() { + guard !isApplyingConfiguration else { + logger.debug("applyConfiguration skipped — already applying (re-entrancy guard)") + return + } + isApplyingConfiguration = true + defer { isApplyingConfiguration = false } + let session = AVAudioSession.sharedInstance() // 1. Build category options from bluetooth mode. @@ -159,8 +178,9 @@ final class IOSAudioRouter: ObservableObject { do { try session.setCategory(.playAndRecord, mode: mode, options: options) + logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue)") } catch { - print("[IOSAudioRouter] setCategory failed: \(error)") + logger.error("setCategory failed: \(error.localizedDescription)") } // 3. Set preferred input port. @@ -168,8 +188,9 @@ final class IOSAudioRouter: ObservableObject { let port = session.availableInputs?.first(where: { $0.uid == portId }) { do { try session.setPreferredInput(port) + logger.info("setPreferredInput ok — \(port.portName)") } catch { - print("[IOSAudioRouter] setPreferredInput failed: \(error)") + logger.error("setPreferredInput failed: \(error.localizedDescription)") } // 4. Set preferred data source (orientation) on the selected input port. @@ -177,8 +198,9 @@ final class IOSAudioRouter: ObservableObject { let dataSource = port.dataSources?.first(where: { String(describing: $0.dataSourceID) == dataSourceId }) { do { try port.setPreferredDataSource(dataSource) + logger.info("setPreferredDataSource ok — \(dataSource.dataSourceName)") } catch { - print("[IOSAudioRouter] setPreferredDataSource failed: \(error)") + logger.error("setPreferredDataSource failed: \(error.localizedDescription)") } // 5. Set preferred polar pattern on the data source. @@ -186,8 +208,9 @@ final class IOSAudioRouter: ObservableObject { let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern) do { try dataSource.setPreferredPolarPattern(pattern) + logger.info("setPreferredPolarPattern ok — \(polarPattern)") } catch { - print("[IOSAudioRouter] setPreferredPolarPattern failed: \(error)") + logger.error("setPreferredPolarPattern failed: \(error.localizedDescription)") } } } @@ -196,13 +219,22 @@ final class IOSAudioRouter: ObservableObject { // 6. Set preferred input number of channels (stereo capture). do { try session.setPreferredInputNumberOfChannels(Int(captureChannels.channelCount)) + logger.info("setPreferredInputNumberOfChannels ok — \(self.captureChannels.rawValue)") } catch { - print("[IOSAudioRouter] setPreferredInputNumberOfChannels failed: \(error)") + logger.error("setPreferredInputNumberOfChannels failed: \(error.localizedDescription)") } updateRawModeWarning() } + private func modeLabel(_ mode: AVAudioSession.Mode) -> String { + switch mode { + case .voiceChat: return "voiceChat" + case .measurement: return "measurement" + default: return "other" + } + } + /// Apply stored preferences from UserDefaults. Called at app launch (before any /// audio session activation). func loadStoredPreferences() {