Diagnosed by comparing against TeamTalk5 (Client/iTeamTalk), which achieves stereo mic + A2DP output. Five fixes: 1. configureStereoCapture now calls setPreferredInput + setInputDataSource (mirroring TeamTalk5's SoundDevicesModel). Previously omitted based on incorrect diagnosis that setPreferredInput collapsed A2DP — the real culprit was setPreferredInputNumberOfChannels(2), which neither project uses. 2. New C ABI: vc_audio_restart (full stop + re-init, unlike suspend/resume which only stop/start). Swift wrapper added. The withAudioSuspend wrapper that used it was removed after on-device testing showed it killed all audio (including VoiceOver) when switching presets — the core's set_capture_channels handles engine restart internally. 3. Bluetooth options: Voice Chat preset now includes BOTH .allowBluetoothHFP AND .allowBluetoothA2DP (matching TeamTalk5's UtilSound.swift:228). Previously HFP-only blocked A2DP headphones. 4. Capture channels now reset when switching stereo→mono via selectCaptureChannels/applyPreset. AudioSessionManager tracks activeMicStreamId (set by SessionState on join/leave voice). 5. Docs synced: voice.md, tech-stack.md, architecture.md, PROGRESS.md. Removed stale setPreferredInputNumberOfChannels(2) references. Verified: ctest --preset dev 21/21 green, iOS client builds. Stereo mic + A2DP output still needs on-device debugging — the core recipe is correct but iOS 26 route behavior requires hands-on testing with a debugger.
155 lines
7.4 KiB
Swift
155 lines
7.4 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()
|
|
|
|
weak var client: VoiceCatClient?
|
|
|
|
/// The stream ID of the currently active local MIC stream, if any. Set by `SessionState`
|
|
/// when the user joins/leaves voice so `IOSAudioRouter` can reset the core's capture
|
|
/// channel count (e.g. when switching stereo → mono) without going through `SessionState`.
|
|
var activeMicStreamId: UInt32?
|
|
|
|
/// Tracks whether WE activated the session. The session must be active whenever the
|
|
/// AudioEngine is running (for capture OR playback). Previously, the session was only
|
|
/// activated when the user joined voice (startMicStream), which meant:
|
|
/// - Remote audio was silent if the user hadn't joined voice yet.
|
|
/// - Leaving voice (stopMicStream) deactivated the session, killing remote audio.
|
|
/// Now the session is activated when any audio needs to play (remote stream started OR
|
|
/// user joins voice) and only deactivated when disconnecting from the server.
|
|
private var isSessionActive = false
|
|
|
|
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, make sure output isn't pinned to the built-in speaker.
|
|
// A2DP routing in .playAndRecord is fragile; clearing any speaker override after the
|
|
// session is live nudges iOS to honor the Bluetooth output route.
|
|
if IOSAudioRouter.shared.wantsA2dpOutput {
|
|
try? session.overrideOutputAudioPort(.none)
|
|
}
|
|
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)]")
|
|
}
|
|
|
|
/// 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")
|
|
}
|
|
|
|
@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:
|
|
logger.info("interruption began — session suspended by system")
|
|
isSessionActive = false // system deactivated us
|
|
client?.audioSuspend()
|
|
case .ended:
|
|
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
|
if options.contains(.shouldResume) {
|
|
do {
|
|
try AVAudioSession.sharedInstance().setActive(true)
|
|
isSessionActive = true
|
|
logger.info("interruption ended — session reactivated")
|
|
client?.audioResume()
|
|
} 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()
|
|
}
|
|
|
|
IOSAudioRouter.shared.refreshRoutes()
|
|
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
|
}
|
|
|
|
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")
|
|
}
|