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 /
|
||||
|
||||
@@ -158,18 +158,28 @@ final class AudioSessionManager {
|
||||
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.
|
||||
// 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, override,
|
||||
// wakeFromSleep, noSuitableRouteForCategory, unknown) covers headphone/AirPods/wired
|
||||
// unplug-replug — the previously-reported "audio dies when headphones disconnect" bug.
|
||||
if reason != .categoryChange && reason != .routeConfigurationChange {
|
||||
// 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)))")
|
||||
|
||||
@@ -163,6 +163,17 @@ final class IOSAudioRouter: ObservableObject {
|
||||
/// burns CPU and cycles the audio session on/off (the "glitching" bug).
|
||||
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() {}
|
||||
|
||||
// MARK: - Load / refresh from AVAudioSession
|
||||
@@ -298,6 +309,9 @@ final class IOSAudioRouter: ObservableObject {
|
||||
return
|
||||
}
|
||||
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 }
|
||||
|
||||
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 /
|
||||
/// 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.
|
||||
///
|
||||
/// 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() {
|
||||
guard bluetoothMode == .builtInMicBtA2dp else { return }
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
@@ -673,19 +695,32 @@ final class IOSAudioRouter: ObservableObject {
|
||||
let hasExternalOutput = session.currentRoute.outputs.contains {
|
||||
$0.portType != .builtInReceiver && $0.portType != .builtInSpeaker
|
||||
}
|
||||
do {
|
||||
if hasExternalOutput {
|
||||
try session.overrideOutputAudioPort(.none)
|
||||
logger.info("A2DP mode — external output present, clearing speaker override")
|
||||
} else {
|
||||
try session.overrideOutputAudioPort(.speaker)
|
||||
logger.info("A2DP mode — no external output, routing to built-in speaker")
|
||||
let desired: AVAudioSession.PortOverride = hasExternalOutput ? .none : .speaker
|
||||
if desired == lastAppliedOutputOverride {
|
||||
// Already in the desired state — calling overrideOutputAudioPort again would just
|
||||
// fire a redundant `.override` route-change notification (the loop driver).
|
||||
logger.debug("A2DP fallback — desired=\(self.overrideLabel(desired)) already applied, skipping")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try session.overrideOutputAudioPort(desired)
|
||||
lastAppliedOutputOverride = desired
|
||||
logger.info("A2DP mode — override applied: \(self.overrideLabel(desired))")
|
||||
} 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)")
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
var selectedPort: IOSAudioInputPort? {
|
||||
inputPorts.first(where: { $0.id == selectedInputPortId })
|
||||
|
||||
Reference in New Issue
Block a user