import AVFoundation import os import VoiceCatCore private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager") @MainActor final class AudioSessionManager { static let shared = AudioSessionManager() /// Tracks whether WE activated the session. The session must be active whenever the /// AudioEngine is running (for capture OR playback), so it is activated when any audio /// needs to play (a remote stream started OR the user joins voice) and only deactivated /// when disconnecting from the server — not when leaving voice, since the user may still /// want to hear remote audio. private var isSessionActive = false /// Whether the AVAudioSession is currently active (we activated it). Read by `IOSAudioRouter` /// to decide whether the post-activation A2DP speaker fallback can be applied. var isActive: Bool { isSessionActive } 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) } /// The single end-to-end audio recovery path, driven by *intent* (`IOSAudioEngine.isConnected`) /// — not by session bookkeeping flags that can drift out of sync (e.g. an interruption ended /// without `.shouldResume`, which used to leave `isSessionActive` false forever). Safe to call /// speculatively: the underlying calls are idempotent (AVAudioSession.setActive(true), /// `IOSAudioRouter.applyConfiguration` has a re-entrancy guard, `IOSAudioEngine.reconfigure` /// no-ops when not connected). Call this whenever the audio environment changes in a way that /// could have stopped the engine — interruption end, route change, AVAudioEngine /// configuration-change — and we still want audio back. func recoverAudio() { guard IOSAudioEngine.shared.isConnected else { return } do { try ensureSessionActive() } catch { logger.error("recoverAudio — session activate failed: \(error.localizedDescription)") } IOSAudioRouter.shared.applyConfiguration() if isSessionActive { IOSAudioRouter.shared.applyA2dpSpeakerFallback() } IOSAudioEngine.shared.reconfigure() logSessionState("after recoverAudio") } /// 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, pick the right output once the session is live: defer to // a connected A2DP/wired/AirPlay route, but fall back to the loud built-in speaker (not // the quiet earpiece) when nothing external is connected. See applyA2dpSpeakerFallback(). IOSAudioRouter.shared.applyA2dpSpeakerFallback() 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)]") logSessionState("after activate") } /// 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") } /// Log the full AVAudioSession state — category, mode, options, and active route. /// Useful for diagnosing routing issues, e.g. confirming the session stays /// `PlayAndRecord` with `allowBluetoothA2DP` and keeps the A2DP output route even /// after the mic engine starts. func logSessionState(_ when: String) { let s = AVAudioSession.sharedInstance() var opts: [String] = [] let o = s.categoryOptions if o.contains(.mixWithOthers) { opts.append("mixWithOthers") } if o.contains(.duckOthers) { opts.append("duckOthers") } if o.contains(.allowBluetoothHFP) { opts.append("allowBluetoothHFP") } if o.contains(.allowBluetoothA2DP) { opts.append("allowBluetoothA2DP") } if o.contains(.allowAirPlay) { opts.append("allowAirPlay") } if o.contains(.defaultToSpeaker) { opts.append("defaultToSpeaker") } let outs = s.currentRoute.outputs.map { "\($0.portName)[\($0.portType.rawValue)]" } .joined(separator: ", ") let ins = s.currentRoute.inputs.map { "\($0.portName)[\($0.portType.rawValue)]" } .joined(separator: ", ") logger.info(""" [SESSION @ \(when, privacy: .public)] category=\(s.category.rawValue, privacy: .public) \ mode=\(s.mode.rawValue, privacy: .public) options=[\(opts.joined(separator: ","), privacy: .public)] \ inputs=[\(ins, privacy: .public)] outputs=[\(outs, privacy: .public)] \ inputCh=\(s.inputNumberOfChannels) outputCh=\(s.outputNumberOfChannels) """) } @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: // The system stops our AVAudioEngine and deactivates the session. Nothing to tear // down — `IOSAudioEngine` rebuilds on resume. logger.info("interruption began — session suspended by system") isSessionActive = false case .ended: // Always attempt recovery when we have a live session. iOS sometimes ends an // interruption without the `.shouldResume` hint (e.g. Siri), and the previous // behavior of only reactivating when `.shouldResume` was set left the session // permanently dead — audio never came back. `recoverAudio()` is intent-gated on // `IOSAudioEngine.isConnected` and idempotent, so speculatively calling it is safe. logger.info("interruption ended — recovery requested") recoverAudio() @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 + recovery") IOSAudioRouter.shared.refreshRoutes() NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil) recoverAudio() return } logger.info("routeChange reason=\(self.reasonLabel(reason))") IOSAudioRouter.shared.refreshRoutes() NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil) // Recover audio on every externally-initiated route change. `.categoryChange` and // `.routeConfigurationChange` are fired by our OWN applyConfiguration() calls // (setCategory, setPreferredInput, ...). Acting on them would create a tight ping-pong // loop with the re-entrancy guard (handleRouteChange → recoverAudio → // applyConfiguration → setCategory → routeChange → ...). IOSAudioRouter's guard is the // backstop that bounds it to ONE extra iteration, but skipping these two reasons avoids // even that, so we reconfigure only in response to genuine environmental changes. // // The recovery set below (oldDeviceUnavailable, newDeviceAvailable, override, // wakeFromSleep, noSuitableRouteForCategory, unknown) covers headphone/AirPods/wired // unplug-replug — the previously-reported "audio dies when headphones disconnect" bug. if reason != .categoryChange && reason != .routeConfigurationChange { recoverAudio() } logSessionState("route change (\(reasonLabel(reason)))") } 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" case .unknown: return "unknown" @unknown default: return "unknown" } } } extension Notification.Name { static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged") }