181 lines
8.8 KiB
Swift
181 lines
8.8 KiB
Swift
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)
|
|
}
|
|
|
|
/// Idempotently restores audio after an interruption or external route change.
|
|
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)
|
|
|
|
// Ignore notifications caused by our own configuration calls; rebuilding for them
|
|
// recursively emits more route changes. Engine-configuration notifications remain
|
|
// the recovery path if a self-initiated change actually stops AVAudioEngine.
|
|
if reason != .categoryChange && reason != .routeConfigurationChange && reason != .override {
|
|
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")
|
|
}
|