diff --git a/PROGRESS.md b/PROGRESS.md index f0a1685..109433b 100644 --- a/PROGRESS.md +++ b/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 / diff --git a/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift b/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift index f2a4d36..c4cf62c 100644 --- a/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift +++ b/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift @@ -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)))") diff --git a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift index 91d528d..03e6d4c 100644 --- a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift +++ b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift @@ -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 } + 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 { - 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") - } + 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 })