Files
voice-cat/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Talon d30c4ee2f5 fix(ios-audio): unify iOS audio onto one always-external AVAudioEngine
The iOS audio path was a hybrid: Voice-Chat-class presets ran a native
VPIO AVAudioEngine (core external) while Stereo/Studio/A2DP presets ran
the core's miniaudio devices. Nearly every "no input / no output / both"
bug lived in the seam between the two paths — the lingering miniaudio
capture unit fighting VPIO, the audioRestart ordering dance, the
route-change "glitching" loop, stereo<->mono stickiness, and
"can't hear anyone". Switching presets/routes mid-call routinely dropped
a direction.

Drive ALL iOS audio through one AVAudioEngine with the core fully
external at all times: setExternalPlayback(1) once at connect, every MIC
stream external_feed=1, mic via vc_stream_feed_pcm, playback via
vc_set_mixed_output_sink (drained by an always-on AVAudioSourceNode so
remote audio plays before joining voice). VPIO + AGC toggle per preset.
Every preset/route/interruption change funnels through one deterministic
Swift-only reconfigure (stop -> apply session config -> rebuild -> start)
— no second path to hand off to, so a change can't drop a direction.

- IOSVoiceProcessingEngine.swift -> IOSAudioEngine: always-on source-node
  playback, conditional mic tap, VPIO/AGC; one rebuild() backing
  startListening/stop/startMic/stopMic/reconfigure/setCaptureChannels.
- IOSAudioRouter: 7 presets -> 4 (Voice Chat / Stereo Mic / Mono Mic /
  Advanced); persisted voiceProcessingEnabled + agcEnabled; setters call
  IOSAudioEngine.reconfigure() instead of audioRestart/reconcileVoicePath.
- AudioSessionManager slimmed; SessionState mic lifecycle collapsed;
  AppState wires external playback + listening at connect, stop at
  disconnect; SettingsView shows 4 presets + Advanced VPIO/AGC toggles.

No core/ABI/test changes — relies on the already-shipped external API
(test_external_pcm, test_external_playback). xcodebuild iOS device Debug
BUILD SUCCEEDED. Updates docs/voice.md §8 and PROGRESS.md.
2026-06-23 02:45:53 +02:00

188 lines
9.6 KiB
Swift

import AVFoundation
import os
import VoiceCatCore
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
@MainActor
final class AudioSessionManager {
static let shared = AudioSessionManager()
/// Tracks whether WE activated the session. The session must be active whenever the
/// AudioEngine is running (for capture OR playback), so it is activated when any audio
/// needs to play (a remote stream started OR the user joins voice) and only deactivated
/// when disconnecting from the server not when leaving voice, since the user may still
/// want to hear remote audio.
private var isSessionActive = false
/// Whether the AVAudioSession is currently active (we activated it). Read by `IOSAudioRouter`
/// to decide whether the post-activation A2DP speaker fallback can be applied.
var isActive: Bool { isSessionActive }
func configure() {
// Load stored audio routing preferences and apply them before any audio session
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
// miniaudio (the core) does NOT touch AVAudioSession on iOS.
IOSAudioRouter.shared.loadStoredPreferences()
IOSAudioRouter.shared.applyConfiguration()
IOSAudioRouter.shared.refreshRoutes()
NotificationCenter.default.addObserver(
self, selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(handleRouteChange),
name: AVAudioSession.routeChangeNotification, object: nil)
}
/// Activate the AVAudioSession if not already active. Call before any audio I/O:
/// when the user joins voice, or when a remote stream starts (so playback works even
/// before the user has joined voice). Idempotent safe to call multiple times.
func ensureSessionActive() throws {
guard !isSessionActive else {
logger.debug("ensureSessionActive — already active, skipping")
return
}
IOSAudioRouter.shared.applyConfiguration()
let session = AVAudioSession.sharedInstance()
try session.setActive(true, options: [])
isSessionActive = true
// For the A2DP output presets, pick the right output once the session is live: defer to
// a connected A2DP/wired/AirPlay route, but fall back to the loud built-in speaker (not
// the quiet earpiece) when nothing external is connected. See applyA2dpSpeakerFallback().
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
let route = AVAudioSession.sharedInstance().currentRoute
let outputNames = route.outputs.map { $0.portName }.joined(separator: ", ")
let inputNames = route.inputs.map { $0.portName }.joined(separator: ", ")
logger.info("session activated — outputs: [\(outputNames)], inputs: [\(inputNames)]")
logSessionState("after activate")
}
/// Deactivate the AVAudioSession. Call ONLY when disconnecting from the server not
/// when leaving voice (the user may still want to hear remote audio).
func deactivateSession() {
guard isSessionActive else {
logger.debug("deactivateSession — not active, skipping")
return
}
try? AVAudioSession.sharedInstance().setActive(false,
options: .notifyOthersOnDeactivation)
isSessionActive = false
logger.info("session deactivated")
}
/// Log the full AVAudioSession state category, mode, options, and active route.
/// Useful for diagnosing routing issues, e.g. confirming the session stays
/// `PlayAndRecord` with `allowBluetoothA2DP` and keeps the A2DP output route even
/// after the mic engine starts.
func logSessionState(_ when: String) {
let s = AVAudioSession.sharedInstance()
var opts: [String] = []
let o = s.categoryOptions
if o.contains(.mixWithOthers) { opts.append("mixWithOthers") }
if o.contains(.duckOthers) { opts.append("duckOthers") }
if o.contains(.allowBluetoothHFP) { opts.append("allowBluetoothHFP") }
if o.contains(.allowBluetoothA2DP) { opts.append("allowBluetoothA2DP") }
if o.contains(.allowAirPlay) { opts.append("allowAirPlay") }
if o.contains(.defaultToSpeaker) { opts.append("defaultToSpeaker") }
let outs = s.currentRoute.outputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
.joined(separator: ", ")
let ins = s.currentRoute.inputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
.joined(separator: ", ")
logger.info("""
[SESSION @ \(when, privacy: .public)] category=\(s.category.rawValue, privacy: .public) \
mode=\(s.mode.rawValue, privacy: .public) options=[\(opts.joined(separator: ","), privacy: .public)] \
inputs=[\(ins, privacy: .public)] outputs=[\(outs, privacy: .public)] \
inputCh=\(s.inputNumberOfChannels) outputCh=\(s.outputNumberOfChannels)
""")
}
@objc private func handleInterruption(_ notification: Notification) {
guard let info = notification.userInfo,
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else { return }
switch type {
case .began:
// The system stops our AVAudioEngine and deactivates the session. Nothing to tear
// down `IOSAudioEngine` rebuilds on resume.
logger.info("interruption began — session suspended by system")
isSessionActive = false
case .ended:
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
do {
IOSAudioRouter.shared.applyConfiguration()
try AVAudioSession.sharedInstance().setActive(true)
isSessionActive = true
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
// Rebuild the engine graph against the restored route (both directions).
IOSAudioEngine.shared.reconfigure()
logger.info("interruption ended — session reactivated, engine rebuilt")
} catch {
logger.error("interruption ended — reactivation failed: \(error.localizedDescription)")
}
}
@unknown default: break
}
}
@objc private func handleRouteChange(_ notification: Notification) {
guard let info = notification.userInfo,
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
else {
logger.warning("routeChange — unknown reason, refreshing only")
IOSAudioRouter.shared.refreshRoutes()
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
return
}
logger.info("routeChange reason=\(self.reasonLabel(reason))")
// Re-apply preferences ONLY on external device plug/unplug. Do NOT re-apply on
// .categoryChange / .routeConfigurationChange those are triggered by our own
// applyConfiguration() calls (setCategory, setPreferredInput, etc.), and re-applying
// would create an infinite notification loop:
// handleRouteChange applyConfiguration setCategory routeChange ...
// That loop burns CPU and cycles the audio session on/off the "glitching" bug.
// IOSAudioRouter.applyConfiguration() also has a re-entrancy guard for synchronous
// notifications, but the reason check here is the primary defense.
if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable {
logger.info("routeChange — external device change, re-applying config")
IOSAudioRouter.shared.applyConfiguration()
// Re-evaluate the A2DP-mode speaker fallback: a Bluetooth unplug should drop us onto
// the loud speaker (not the earpiece), and a replug should hand output back to A2DP.
if isSessionActive {
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
// Rebind the engine (both directions) to the new route. The engine owns the route
// now, so this is the single thing that re-establishes audio after a device change.
IOSAudioEngine.shared.reconfigure()
}
}
IOSAudioRouter.shared.refreshRoutes()
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
logSessionState("route change (\(reasonLabel(reason)))")
}
private func reasonLabel(_ reason: AVAudioSession.RouteChangeReason) -> String {
switch reason {
case .oldDeviceUnavailable: return "oldDeviceUnavailable"
case .newDeviceAvailable: return "newDeviceAvailable"
case .categoryChange: return "categoryChange"
case .override: return "override"
case .wakeFromSleep: return "wakeFromSleep"
case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
case .routeConfigurationChange: return "routeConfigurationChange"
@unknown default: return "unknown"
}
}
}
extension Notification.Name {
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
}