feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired headphones, AirPods) used to leave the iOS client in a dead/zombie state: the engine went silent, no reconnect was attempted, and a live-session disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout. Reconnect (AppState.swift, SessionState.swift): - Two-layer reconcile. Once SessionState overwrites client.onEvent at auth success, AppState.handleConnectEvent no longer sees live-session events. Added a weak SessionState.appState; SessionState.handleEvent .disconnected calls appState.onLiveSessionDisconnected after the cue -- the single path AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect snapshots lastSession, stops audio, releases session/VoiceCatClient (io- thread join via vc_client_destroy), resets the backoff, and arms scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth success via existing TOFU_MATCHED auto-confirm + idempotent join_channel). - NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi <-> cellular interface change or path .unsatisfied it calls proactiveReconnect: tearing the session down BEFORE the C core notices the dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature. While mid-reconnect a .satisfied path resets the backoff for a fast retry. - User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect and cancel all reconnect state (task + monitor + lastSession + connectedServer). Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift): - Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/ .newDeviceAvailable route-change guard; fires on every externally-initiated route change reason except the ones we cause ourselves (.categoryChange/ .routeConfigurationChange) to avoid a notification loop. Interruption-end now always recovers instead of only when .shouldResume is set. - Added AVAudioEngineConfigurationChange observer on the engine so a system self-stop after our route-change handler wins the race is caught. - IOSAudioEngine.rebuild() does a one-shot reactivation-retry on engine.start() failure (iOS sometimes refuses until the session is re-reactivated -- the silent-death case). No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
This commit is contained in:
@@ -35,6 +35,27 @@ final class AudioSessionManager {
|
||||
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.
|
||||
@@ -110,21 +131,13 @@ final class AudioSessionManager {
|
||||
logger.info("interruption began — session suspended by system")
|
||||
isSessionActive = false
|
||||
case .ended:
|
||||
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||
if options.contains(.shouldResume) {
|
||||
do {
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
isSessionActive = true
|
||||
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
||||
// Rebuild the engine graph against the restored route (both directions).
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
logger.info("interruption ended — session reactivated, engine rebuilt")
|
||||
} catch {
|
||||
logger.error("interruption ended — reactivation failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -134,37 +147,31 @@ final class AudioSessionManager {
|
||||
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
||||
else {
|
||||
logger.warning("routeChange — unknown reason, refreshing only")
|
||||
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))")
|
||||
|
||||
// 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()
|
||||
// Re-evaluate the A2DP-mode speaker fallback: a Bluetooth unplug should drop us onto
|
||||
// the loud speaker (not the earpiece), and a replug should hand output back to A2DP.
|
||||
if isSessionActive {
|
||||
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
||||
// Rebind the engine (both directions) to the new route. The engine owns the route
|
||||
// now, so this is the single thing that re-establishes audio after a device change.
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
}
|
||||
}
|
||||
|
||||
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)))")
|
||||
}
|
||||
|
||||
@@ -177,6 +184,7 @@ final class AudioSessionManager {
|
||||
case .wakeFromSleep: return "wakeFromSleep"
|
||||
case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
|
||||
case .routeConfigurationChange: return "routeConfigurationChange"
|
||||
case .unknown: return "unknown"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user