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).
This commit is contained in:
2026-06-25 16:18:21 +02:00
parent 99937c9446
commit 9a20953c08
3 changed files with 108 additions and 18 deletions

View File

@@ -10,6 +10,51 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ Where we left off / next action
- **Done (2026-06-25):** **Fixed iOS AirPods-disconnect reinitialize loop on A2DP presets
(Stereo Mic / Mono Mic).** Regression from the 2026-06-25 audio-device-change recovery
commit below, which broadened the route-change recovery set from
`{oldDeviceUnavailable, newDeviceAvailable}` to "everything except
categoryChange/routeConfigurationChange". That added `.override` to the recovery set, and
`.override` is fired by our own `applyA2dpSpeakerFallback()`
`overrideOutputAudioPort(.speaker)` — which `recoverAudio()` calls on every recovery. On an
A2DP preset with AirPods connected, disconnecting them ran:
`oldDeviceUnavailable``recoverAudio()``applyA2dpSpeakerFallback()` (no external
output now) → `overrideOutputAudioPort(.speaker)``.override` routeChange →
`recoverAudio()``applyConfiguration()` (setCategory resets the override) →
`applyA2dpSpeakerFallback()``overrideOutputAudioPort(.speaker)``.override` → …
Each iteration also called `IOSAudioEngine.reconfigure()``rebuild()` (a full
stop/restart of `AVAudioEngine`), which is the audible reinitialize loop + CPU spin the
user reported. Voice Chat (`.btHfpVoice`) and Built-in Mic + Speaker were unaffected
because `applyA2dpSpeakerFallback` early-returns for non-A2DP modes (no
`overrideOutputAudioPort` call, no `.override` notification).
Two-part fix (no C ABI / proto / docs changes — pure Swift iOS-app target):
1. **`AudioSessionManager.handleRouteChange`** (`AudioSessionManager.swift:182`): 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 for the case where an override actually stops the engine.
2. **`IOSAudioRouter.applyA2dpSpeakerFallback`** (`IOSAudioRouter.swift`): made idempotent
via a `lastAppliedOutputOverride` tracker. Skips the `overrideOutputAudioPort` call
when the desired override (`.none` for external output present, `.speaker` otherwise)
already matches the last successfully applied value — so even if some other path
re-enters, the redundant override (and its `.override` notification) isn't fired. The
tracker is 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). The standalone
`swift test` in `clients/apple/` fails with `no such module 'VoiceCatC'` — pre-existing
(confirmed by stashing the changes: fails identically without them); the `VoiceCatC` C ABI
XCFramework isn't on SwiftPM's resolver path in this workspace. Not caused by this change
(the edit is in the iOS app target, not the `VoiceCatCore` SwiftPM package).
**Next (manual, on-device):** connect on the Stereo Mic preset, join voice, disconnect
AirPods — expect ONE `oldDeviceUnavailable` → one `recoverAudio` → one `engine started`
one `override` routeChange (skipped, no further `recoverAudio`) and steady audio through
the loudspeaker. Also sanity-check AirPods reconnect and wired headphone plug/unplug
recover exactly once.
- **Done (2026-06-25):** **iOS robustness — auto-reconnect after a network change + audio - **Done (2026-06-25):** **iOS robustness — auto-reconnect after a network change + audio
recovery when audio devices plug/unplug.** Two layers of bugs the iOS client had: recovery when audio devices plug/unplug.** Two layers of bugs the iOS client had:
(a) a `VC_EVENT_DISCONNECTED` from the C core on a Wi-Fi→cellular flip / DNS outage / (a) a `VC_EVENT_DISCONNECTED` from the C core on a Wi-Fi→cellular flip / DNS outage /

View File

@@ -158,18 +158,28 @@ final class AudioSessionManager {
IOSAudioRouter.shared.refreshRoutes() IOSAudioRouter.shared.refreshRoutes()
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil) NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
// Recover audio on every externally-initiated route change. `.categoryChange` and // Recover audio on every externally-initiated route change. `.categoryChange`,
// `.routeConfigurationChange` are fired by our OWN applyConfiguration() calls // `.routeConfigurationChange`, and `.override` are fired by our OWN calls:
// (setCategory, setPreferredInput, ...). Acting on them would create a tight ping-pong // - `.categoryChange` / `.routeConfigurationChange` applyConfiguration()'s
// loop with the re-entrancy guard (handleRouteChange recoverAudio // setCategory / setPreferredInput / ...
// applyConfiguration setCategory routeChange ...). IOSAudioRouter's guard is the // - `.override` applyA2dpSpeakerFallback()'s overrideOutputAudioPort(.speaker),
// backstop that bounds it to ONE extra iteration, but skipping these two reasons avoids // which fires on every AirPods disconnect (and reconnect) on an A2DP preset.
// even that, so we reconfigure only in response to genuine environmental changes. // 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, override, // The recovery set below (oldDeviceUnavailable, newDeviceAvailable, wakeFromSleep,
// wakeFromSleep, noSuitableRouteForCategory, unknown) covers headphone/AirPods/wired // noSuitableRouteForCategory, unknown) covers headphone/AirPods/wired unplug-replug
// unplug-replug the previously-reported "audio dies when headphones disconnect" bug. // the previously-reported "audio dies when headphones disconnect" bug. If an
if reason != .categoryChange && reason != .routeConfigurationChange { // override ever actually stops the AVAudioEngine, the
// AVAudioEngineConfigurationChange handler in IOSVoiceProcessingEngine catches it.
if reason != .categoryChange && reason != .routeConfigurationChange && reason != .override {
recoverAudio() recoverAudio()
} }
logSessionState("route change (\(reasonLabel(reason)))") logSessionState("route change (\(reasonLabel(reason)))")

View File

@@ -163,6 +163,17 @@ final class IOSAudioRouter: ObservableObject {
/// burns CPU and cycles the audio session on/off (the "glitching" bug). /// burns CPU and cycles the audio session on/off (the "glitching" bug).
private var isApplyingConfiguration = false private var isApplyingConfiguration = false
/// Last `overrideOutputAudioPort` value we successfully applied (`.none` or `.speaker`),
/// so `applyA2dpSpeakerFallback` can skip a redundant `overrideOutputAudioPort` call.
/// That call fires a `.override` route-change notification on every invocation, and on
/// an AirPods disconnect the fallback is invoked once per `recoverAudio()` which
/// itself fires on every non-skipped route change so without this guard the override
/// call and the route-change handler ping-pong: the AirPods-disconnect reinitialize
/// loop (each iteration also rebuilds the AVAudioEngine via reconfigure()).
/// `nil` = "unknown / assume not applied" reset at the top of `applyConfiguration()`
/// because `setCategory` can reset the override out from under us, and on first run.
private var lastAppliedOutputOverride: AVAudioSession.PortOverride?
private init() {} private init() {}
// MARK: - Load / refresh from AVAudioSession // MARK: - Load / refresh from AVAudioSession
@@ -298,6 +309,9 @@ final class IOSAudioRouter: ObservableObject {
return return
} }
isApplyingConfiguration = true isApplyingConfiguration = true
// setCategory below can reset the override out from under us, so drop our cached
// value applyA2dpSpeakerFallback will re-derive and re-apply it from scratch.
lastAppliedOutputOverride = nil
defer { isApplyingConfiguration = false } defer { isApplyingConfiguration = false }
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
@@ -665,6 +679,14 @@ final class IOSAudioRouter: ObservableObject {
/// else speaker" behavior. When an external output IS present we clear the override so A2DP / /// else speaker" behavior. When an external output IS present we clear the override so A2DP /
/// headphones / AirPlay are honored. No-op outside `.builtInMicBtA2dp` mode (other modes pick /// headphones / AirPlay are honored. No-op outside `.builtInMicBtA2dp` mode (other modes pick
/// their route via category options). Must be called AFTER the session is active. /// their route via category options). Must be called AFTER the session is active.
///
/// Idempotent: skips the `overrideOutputAudioPort` call when the desired override already
/// matches the last one we successfully applied. Each call fires a `.override` route-change
/// notification, and `AudioSessionManager.recoverAudio()` invokes this on every recovery
/// so on an AirPods disconnect, without this guard, override + recoverAudio ping-pong and
/// each iteration also rebuilds the AVAudioEngine (the audible reinitialize loop). The cache
/// is reset to `nil` at the top of `applyConfiguration()` (setCategory can reset the
/// override) and on a failed call (so the next attempt re-derives from the live session).
func applyA2dpSpeakerFallback() { func applyA2dpSpeakerFallback() {
guard bluetoothMode == .builtInMicBtA2dp else { return } guard bluetoothMode == .builtInMicBtA2dp else { return }
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
@@ -673,19 +695,32 @@ final class IOSAudioRouter: ObservableObject {
let hasExternalOutput = session.currentRoute.outputs.contains { let hasExternalOutput = session.currentRoute.outputs.contains {
$0.portType != .builtInReceiver && $0.portType != .builtInSpeaker $0.portType != .builtInReceiver && $0.portType != .builtInSpeaker
} }
do { let desired: AVAudioSession.PortOverride = hasExternalOutput ? .none : .speaker
if hasExternalOutput { if desired == lastAppliedOutputOverride {
try session.overrideOutputAudioPort(.none) // Already in the desired state calling overrideOutputAudioPort again would just
logger.info("A2DP mode — external output present, clearing speaker override") // fire a redundant `.override` route-change notification (the loop driver).
} else { logger.debug("A2DP fallback — desired=\(self.overrideLabel(desired)) already applied, skipping")
try session.overrideOutputAudioPort(.speaker) return
logger.info("A2DP mode — no external output, routing to built-in speaker")
} }
do {
try session.overrideOutputAudioPort(desired)
lastAppliedOutputOverride = desired
logger.info("A2DP mode — override applied: \(self.overrideLabel(desired))")
} catch { } catch {
// Drop the cache so the next call re-derives from the live session state.
lastAppliedOutputOverride = nil
logger.error("A2DP speaker fallback failed: \(error.localizedDescription)") logger.error("A2DP speaker fallback failed: \(error.localizedDescription)")
} }
} }
private func overrideLabel(_ o: AVAudioSession.PortOverride) -> String {
switch o {
case .none: return "none"
case .speaker: return "speaker"
@unknown default: return "unknown"
}
}
/// The selected input port object, if any. /// The selected input port object, if any.
var selectedPort: IOSAudioInputPort? { var selectedPort: IOSAudioInputPort? {
inputPorts.first(where: { $0.id == selectedInputPortId }) inputPorts.first(where: { $0.id == selectedInputPortId })