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? /// The stream ID of the currently active local MIC stream, if any. Set by `SessionState` /// when the user joins/leaves voice so `IOSAudioRouter` can reset the core's capture /// channel count (e.g. when switching stereo → mono) without going through `SessionState`. var activeMicStreamId: UInt32? /// Tracks whether WE activated the session. The session must be active whenever the /// AudioEngine is running (for capture OR playback). Previously, the session was only /// activated when the user joined voice (startMicStream), which meant: /// - Remote audio was silent if the user hadn't joined voice yet. /// - Leaving voice (stopMicStream) deactivated the session, killing remote audio. /// Now the session is activated when any audio needs to play (remote stream started OR /// user joins voice) and only deactivated when disconnecting from the server. private var isSessionActive = false 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) } /// Activate the AVAudioSession if not already active. Call before any audio I/O: /// when the user joins voice, or when a remote stream starts (so playback works even /// before the user has joined voice). Idempotent — safe to call multiple times. func ensureSessionActive() throws { guard !isSessionActive else { logger.debug("ensureSessionActive — already active, skipping") return } IOSAudioRouter.shared.applyConfiguration() let session = AVAudioSession.sharedInstance() try session.setActive(true, options: []) isSessionActive = true // For the A2DP output presets, make sure output isn't pinned to the built-in speaker. // A2DP routing in .playAndRecord is fragile; clearing any speaker override after the // session is live nudges iOS to honor the Bluetooth output route. if IOSAudioRouter.shared.wantsA2dpOutput { try? session.overrideOutputAudioPort(.none) } let route = AVAudioSession.sharedInstance().currentRoute let outputNames = route.outputs.map { $0.portName }.joined(separator: ", ") let inputNames = route.inputs.map { $0.portName }.joined(separator: ", ") logger.info("session activated — outputs: [\(outputNames)], inputs: [\(inputNames)]") } /// Deactivate the AVAudioSession. Call ONLY when disconnecting from the server — not /// when leaving voice (the user may still want to hear remote audio). func deactivateSession() { guard isSessionActive else { logger.debug("deactivateSession — not active, skipping") return } try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) isSessionActive = false logger.info("session deactivated") } @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: logger.info("interruption began — session suspended by system") isSessionActive = false // system deactivated us client?.audioSuspend() case .ended: let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0 let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) { do { try AVAudioSession.sharedInstance().setActive(true) isSessionActive = true logger.info("interruption ended — session reactivated") client?.audioResume() } catch { logger.error("interruption ended — reactivation failed: \(error.localizedDescription)") } } @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") }