Files
voice-cat/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Talon 9a20953c08 fix(ios): break AirPods-disconnect reinitialize loop on A2DP presets
Recovery from the 99937c9 audio-device-change commit broadened the
route-change recovery set to "everything except categoryChange /
routeConfigurationChange", which added .override. But .override is
fired by our own applyA2dpSpeakerFallback() -> overrideOutputAudioPort,
which recoverAudio() calls on every recovery. On an A2DP preset
(Stereo Mic / Mono Mic), disconnecting AirPods ping-ponged:

  oldDeviceUnavailable -> recoverAudio -> applyA2dpSpeakerFallback
  -> overrideOutputAudioPort(.speaker) -> .override routeChange
  -> recoverAudio -> applyConfiguration (setCategory resets override)
  -> applyA2dpSpeakerFallback -> overrideOutputAudioPort -> .override -> ...

Each iteration also rebuilt the AVAudioEngine via reconfigure() ->
rebuild() -- the audible reinitialize loop + CPU spin. Voice Chat and
Built-in Mic + Speaker were unaffected (applyA2dpSpeakerFallback
early-returns for non-A2DP modes, so no overrideOutputAudioPort call).

Two-part fix (pure Swift iOS-app target; no C ABI / proto / docs changes):
1. AudioSessionManager.handleRouteChange: added .override to the skip
   list alongside .categoryChange / .routeConfigurationChange. .override
   is only ever fired by our own overrideOutputAudioPort call, so
   treating it as a recovery reason is the loop by definition. The
   AVAudioEngineConfigurationChange observer in IOSVoiceProcessingEngine
   remains as the backstop if an override ever actually stops the engine.
2. IOSAudioRouter.applyA2dpSpeakerFallback: made idempotent via a
   lastAppliedOutputOverride tracker that skips the redundant
   overrideOutputAudioPort call when the desired state (.none for
   external output present, .speaker otherwise) already matches. Reset
   to nil at the top of applyConfiguration() (setCategory can reset the
   override) and on a failed call. Defense-in-depth on top of fix 1.

Build: xcodebuild -project clients/apple/iOS/VoiceCatiOS.xcodeproj
-scheme VoiceCatiOS -destination 'generic/platform=iOS' build green
(Xcode 26.5 / iOS 18.0).
2026-06-25 16:18:21 +02:00

206 lines
11 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)
}
/// 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`,
// `.routeConfigurationChange`, and `.override` are fired by our OWN calls:
// - `.categoryChange` / `.routeConfigurationChange` applyConfiguration()'s
// setCategory / setPreferredInput / ...
// - `.override` applyA2dpSpeakerFallback()'s overrideOutputAudioPort(.speaker),
// which fires on every AirPods disconnect (and reconnect) on an A2DP preset.
// Acting on any of these would create a tight ping-pong loop with the re-entrancy
// guard (handleRouteChange recoverAudio applyA2dpSpeakerFallback
// overrideOutputAudioPort .override routeChange recoverAudio ...). The
// `.override` skip is what fixes the AirPods-disconnect reinitialize loop: each
// iteration also calls IOSAudioEngine.reconfigure() rebuild() (a full
// stop/restart of AVAudioEngine), which is the audible cycling. IOSAudioRouter's
// guard is the backstop that bounds it to ONE extra iteration, but skipping these
// three reasons avoids even that, so we reconfigure only in response to genuine
// environmental changes.
//
// The recovery set below (oldDeviceUnavailable, newDeviceAvailable, wakeFromSleep,
// noSuitableRouteForCategory, unknown) covers headphone/AirPods/wired unplug-replug
// the previously-reported "audio dies when headphones disconnect" bug. If an
// override ever actually stops the AVAudioEngine, the
// AVAudioEngineConfigurationChange handler in IOSVoiceProcessingEngine catches it.
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")
}