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 AVFoundation
|
||||||
|
import os
|
||||||
import VoiceCatCore
|
import VoiceCatCore
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AudioSessionManager {
|
final class AudioSessionManager {
|
||||||
static let shared = AudioSessionManager()
|
static let shared = AudioSessionManager()
|
||||||
@@ -57,12 +60,47 @@ final class AudioSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleRouteChange(_ notification: Notification) {
|
@objc private func handleRouteChange(_ notification: Notification) {
|
||||||
// Refresh the router's published state so the Settings UI updates, and re-apply
|
guard let info = notification.userInfo,
|
||||||
// the stored preferences (the new route may need the preferred input re-set).
|
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.refreshRoutes()
|
||||||
IOSAudioRouter.shared.applyConfiguration()
|
|
||||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
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 {
|
extension Notification.Name {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import os
|
||||||
import VoiceCatCore
|
import VoiceCatCore
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
|
||||||
|
|
||||||
/// iOS audio routing layer — drives all iOS audio route selection via `AVAudioSession`
|
/// iOS audio routing layer — drives all iOS audio route selection via `AVAudioSession`
|
||||||
/// *before* the core (miniaudio) opens its device. miniaudio does NOT touch
|
/// *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
|
/// `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 kDataSourceId = "cat.voice.audio.dataSourceId"
|
||||||
private let kPolarPattern = "cat.voice.audio.polarPattern"
|
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() {}
|
private init() {}
|
||||||
|
|
||||||
// MARK: - Load / refresh from AVAudioSession
|
// 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
|
/// Apply the full audio configuration to AVAudioSession. Call this before the core
|
||||||
/// opens its capture device (i.e. before `startMicStream` → `activateForStreaming`).
|
/// 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() {
|
func applyConfiguration() {
|
||||||
|
guard !isApplyingConfiguration else {
|
||||||
|
logger.debug("applyConfiguration skipped — already applying (re-entrancy guard)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isApplyingConfiguration = true
|
||||||
|
defer { isApplyingConfiguration = false }
|
||||||
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
|
||||||
// 1. Build category options from bluetooth mode.
|
// 1. Build category options from bluetooth mode.
|
||||||
@@ -159,8 +178,9 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try session.setCategory(.playAndRecord, mode: mode, options: options)
|
try session.setCategory(.playAndRecord, mode: mode, options: options)
|
||||||
|
logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue)")
|
||||||
} catch {
|
} catch {
|
||||||
print("[IOSAudioRouter] setCategory failed: \(error)")
|
logger.error("setCategory failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Set preferred input port.
|
// 3. Set preferred input port.
|
||||||
@@ -168,8 +188,9 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
||||||
do {
|
do {
|
||||||
try session.setPreferredInput(port)
|
try session.setPreferredInput(port)
|
||||||
|
logger.info("setPreferredInput ok — \(port.portName)")
|
||||||
} catch {
|
} catch {
|
||||||
print("[IOSAudioRouter] setPreferredInput failed: \(error)")
|
logger.error("setPreferredInput failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Set preferred data source (orientation) on the selected input port.
|
// 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 }) {
|
let dataSource = port.dataSources?.first(where: { String(describing: $0.dataSourceID) == dataSourceId }) {
|
||||||
do {
|
do {
|
||||||
try port.setPreferredDataSource(dataSource)
|
try port.setPreferredDataSource(dataSource)
|
||||||
|
logger.info("setPreferredDataSource ok — \(dataSource.dataSourceName)")
|
||||||
} catch {
|
} catch {
|
||||||
print("[IOSAudioRouter] setPreferredDataSource failed: \(error)")
|
logger.error("setPreferredDataSource failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Set preferred polar pattern on the data source.
|
// 5. Set preferred polar pattern on the data source.
|
||||||
@@ -186,8 +208,9 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
|
let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
|
||||||
do {
|
do {
|
||||||
try dataSource.setPreferredPolarPattern(pattern)
|
try dataSource.setPreferredPolarPattern(pattern)
|
||||||
|
logger.info("setPreferredPolarPattern ok — \(polarPattern)")
|
||||||
} catch {
|
} 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).
|
// 6. Set preferred input number of channels (stereo capture).
|
||||||
do {
|
do {
|
||||||
try session.setPreferredInputNumberOfChannels(Int(captureChannels.channelCount))
|
try session.setPreferredInputNumberOfChannels(Int(captureChannels.channelCount))
|
||||||
|
logger.info("setPreferredInputNumberOfChannels ok — \(self.captureChannels.rawValue)")
|
||||||
} catch {
|
} catch {
|
||||||
print("[IOSAudioRouter] setPreferredInputNumberOfChannels failed: \(error)")
|
logger.error("setPreferredInputNumberOfChannels failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRawModeWarning()
|
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
|
/// Apply stored preferences from UserDefaults. Called at app launch (before any
|
||||||
/// audio session activation).
|
/// audio session activation).
|
||||||
func loadStoredPreferences() {
|
func loadStoredPreferences() {
|
||||||
|
|||||||
Reference in New Issue
Block a user