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
- **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
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 /