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:
2026-06-19 13:26:23 +02:00
parent 9fc51cffc4
commit ab973940df
2 changed files with 78 additions and 8 deletions

View File

@@ -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() {