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:
45
PROGRESS.md
45
PROGRESS.md
@@ -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 /
|
||||
|
||||
Reference in New Issue
Block a user