fix(ios): output muted with A2DP, session lifecycle, mic input issues
Three bugs causing no audio output and no mic input:
1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware
AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS
mutes the output because it can't set up the voice processing pipeline on
an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC,
but audio routes correctly). .voiceChat kept for HFP and speaker modes.
Added info warning in Settings UI for A2DP no-AEC.
2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming()
which deactivated the AVAudioSession — but the AudioEngine keeps running for
remote audio playback, so leaving voice killed all remote audio. And the
session was never activated when a remote user started talking (only on
Join Voice), so you couldn't hear anyone before joining voice. Fix:
- ensureSessionActive() replaces activateForStreaming() — idempotent, called
on Join Voice AND on .streamStarted (remote user starts talking).
- stopMicStream() no longer deactivates the session.
- deactivateSession() called only on disconnect from server.
- isSessionActive flag tracks state, updated by interruption handler.
3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is
the default) and may put the session in a bad state on some devices. Fix:
only call it when stereo is explicitly selected. Also handle empty input
port ID (selecting 'Default' in the picker) correctly.
Added comprehensive route logging — after activation, logs the current output
and input route names so issues can be diagnosed from Console.app.
This commit is contained in:
@@ -89,7 +89,9 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
|
session?.stopMicStream()
|
||||||
session?.client.disconnect()
|
session?.client.disconnect()
|
||||||
|
AudioSessionManager.shared.deactivateSession()
|
||||||
session = nil
|
session = nil
|
||||||
connectingClient?.disconnect()
|
connectingClient?.disconnect()
|
||||||
connectingClient = nil
|
connectingClient = nil
|
||||||
@@ -163,7 +165,10 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
case .disconnected:
|
case .disconnected:
|
||||||
if session == nil { cancelConnect() }
|
if session == nil { cancelConnect() }
|
||||||
else { session = nil; isConnecting = false }
|
else {
|
||||||
|
AudioSessionManager.shared.deactivateSession()
|
||||||
|
session = nil; isConnecting = false
|
||||||
|
}
|
||||||
case .error:
|
case .error:
|
||||||
connectStatus = ev.text ?? "Unknown error"
|
connectStatus = ev.text ?? "Unknown error"
|
||||||
if session == nil { isConnecting = false }
|
if session == nil { isConnecting = false }
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ final class AudioSessionManager {
|
|||||||
|
|
||||||
weak var client: VoiceCatClient?
|
weak var client: VoiceCatClient?
|
||||||
|
|
||||||
|
/// 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() {
|
func configure() {
|
||||||
// Load stored audio routing preferences and apply them before any audio session
|
// Load stored audio routing preferences and apply them before any audio session
|
||||||
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
|
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
|
||||||
@@ -26,17 +35,34 @@ final class AudioSessionManager {
|
|||||||
name: AVAudioSession.routeChangeNotification, object: nil)
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func activateForStreaming() throws {
|
/// Activate the AVAudioSession if not already active. Call before any audio I/O:
|
||||||
// Re-apply the routing configuration before activating, in case the user changed
|
/// when the user joins voice, or when a remote stream starts (so playback works even
|
||||||
// settings since the last apply. The core (miniaudio) will open whatever route
|
/// before the user has joined voice). Idempotent — safe to call multiple times.
|
||||||
// AVAudioSession has established.
|
func ensureSessionActive() throws {
|
||||||
|
guard !isSessionActive else {
|
||||||
|
logger.debug("ensureSessionActive — already active, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
IOSAudioRouter.shared.applyConfiguration()
|
IOSAudioRouter.shared.applyConfiguration()
|
||||||
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||||
|
isSessionActive = true
|
||||||
|
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)]")
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivateAfterStreaming() {
|
/// 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,
|
try? AVAudioSession.sharedInstance().setActive(false,
|
||||||
options: .notifyOthersOnDeactivation)
|
options: .notifyOthersOnDeactivation)
|
||||||
|
isSessionActive = false
|
||||||
|
logger.info("session deactivated")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleInterruption(_ notification: Notification) {
|
@objc private func handleInterruption(_ notification: Notification) {
|
||||||
@@ -47,13 +73,21 @@ final class AudioSessionManager {
|
|||||||
|
|
||||||
switch type {
|
switch type {
|
||||||
case .began:
|
case .began:
|
||||||
|
logger.info("interruption began — session suspended by system")
|
||||||
|
isSessionActive = false // system deactivated us
|
||||||
client?.audioSuspend()
|
client?.audioSuspend()
|
||||||
case .ended:
|
case .ended:
|
||||||
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||||
if options.contains(.shouldResume) {
|
if options.contains(.shouldResume) {
|
||||||
try? AVAudioSession.sharedInstance().setActive(true)
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
isSessionActive = true
|
||||||
|
logger.info("interruption ended — session reactivated")
|
||||||
client?.audioResume()
|
client?.audioResume()
|
||||||
|
} catch {
|
||||||
|
logger.error("interruption ended — reactivation failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@unknown default: break
|
@unknown default: break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
@Published var selectedDataSourceId: String?
|
@Published var selectedDataSourceId: String?
|
||||||
@Published var selectedPolarPattern: String?
|
@Published var selectedPolarPattern: String?
|
||||||
@Published var showsRawModeSpeakerWarning: Bool = false
|
@Published var showsRawModeSpeakerWarning: Bool = false
|
||||||
|
@Published var showsA2dpNoAecWarning: Bool = false
|
||||||
|
|
||||||
enum BluetoothMode: String, CaseIterable, Identifiable {
|
enum BluetoothMode: String, CaseIterable, Identifiable {
|
||||||
case btHfpVoice = "BT HFP Voice"
|
case btHfpVoice = "BT HFP Voice"
|
||||||
@@ -134,7 +135,7 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
selectedPolarPattern = currentPolarPattern
|
selectedPolarPattern = currentPolarPattern
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRawModeWarning()
|
updateWarnings()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Apply configuration
|
// MARK: - Apply configuration
|
||||||
@@ -167,24 +168,33 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Set category + mode based on mic processing mode.
|
// 2. Set category + mode based on mic processing mode AND bluetooth mode.
|
||||||
|
// .voiceChat mode uses hardware AEC/AGC/HPF, but requires HFP-compatible routes.
|
||||||
|
// A2DP output is NOT HFP — using .voiceChat with A2DP causes iOS to mute the output
|
||||||
|
// because it can't set up the voice processing pipeline on an A2DP route. So:
|
||||||
|
// - Standard + HFP or Speaker: .voiceChat (hardware AEC works)
|
||||||
|
// - Standard + A2DP: .default (no hardware AEC, but audio routes correctly — A2DP
|
||||||
|
// headphones are in-ear/over-ear so echo from built-in mic is minimal)
|
||||||
|
// - Raw + any: .measurement (all processing off, regardless of bluetooth mode)
|
||||||
let mode: AVAudioSession.Mode
|
let mode: AVAudioSession.Mode
|
||||||
switch micMode {
|
switch (micMode, bluetoothMode) {
|
||||||
case .standard:
|
case (.standard, .builtInMicBtA2dp):
|
||||||
mode = .voiceChat // AEC/AGC/HPF on
|
mode = .default // A2DP + hardware AEC = incompatible
|
||||||
case .raw:
|
case (.standard, _):
|
||||||
|
mode = .voiceChat // HFP or speaker: hardware AEC works
|
||||||
|
case (.raw, _):
|
||||||
mode = .measurement // all processing off
|
mode = .measurement // all processing off
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try session.setCategory(.playAndRecord, mode: mode, options: options)
|
try session.setCategory(.playAndRecord, mode: mode, options: options)
|
||||||
logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue)")
|
logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue), options=\(self.optionsLabel(options))")
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("setCategory failed: \(error.localizedDescription)")
|
logger.error("setCategory failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Set preferred input port.
|
// 3. Set preferred input port (skip if "Default" — empty/nil ID means use system default).
|
||||||
if let portId = selectedInputPortId,
|
if let portId = selectedInputPortId, !portId.isEmpty,
|
||||||
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
||||||
do {
|
do {
|
||||||
try session.setPreferredInput(port)
|
try session.setPreferredInput(port)
|
||||||
@@ -194,7 +204,7 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Set preferred data source (orientation) on the selected input port.
|
// 4. Set preferred data source (orientation) on the selected input port.
|
||||||
if let dataSourceId = selectedDataSourceId,
|
if let dataSourceId = selectedDataSourceId, !dataSourceId.isEmpty,
|
||||||
let dataSource = port.dataSources?.first(where: { String(describing: $0.dataSourceID) == dataSourceId }) {
|
let dataSource = port.dataSources?.first(where: { String(describing: $0.dataSourceID) == dataSourceId }) {
|
||||||
do {
|
do {
|
||||||
try port.setPreferredDataSource(dataSource)
|
try port.setPreferredDataSource(dataSource)
|
||||||
@@ -204,7 +214,7 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Set preferred polar pattern on the data source.
|
// 5. Set preferred polar pattern on the data source.
|
||||||
if let polarPattern = selectedPolarPattern {
|
if let polarPattern = selectedPolarPattern, !polarPattern.isEmpty {
|
||||||
let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
|
let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
|
||||||
do {
|
do {
|
||||||
try dataSource.setPreferredPolarPattern(pattern)
|
try dataSource.setPreferredPolarPattern(pattern)
|
||||||
@@ -216,25 +226,39 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Set preferred input number of channels (stereo capture).
|
// 6. Set preferred input number of channels — ONLY for stereo (non-default).
|
||||||
|
// Calling setPreferredInputNumberOfChannels(1) for mono is unnecessary (1 is the
|
||||||
|
// default) and may put the session in a bad state on some devices.
|
||||||
|
if captureChannels == .stereo {
|
||||||
do {
|
do {
|
||||||
try session.setPreferredInputNumberOfChannels(Int(captureChannels.channelCount))
|
try session.setPreferredInputNumberOfChannels(2)
|
||||||
logger.info("setPreferredInputNumberOfChannels ok — \(self.captureChannels.rawValue)")
|
logger.info("setPreferredInputNumberOfChannels ok — 2 (stereo)")
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("setPreferredInputNumberOfChannels failed: \(error.localizedDescription)")
|
logger.error("setPreferredInputNumberOfChannels failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateRawModeWarning()
|
updateWarnings()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func modeLabel(_ mode: AVAudioSession.Mode) -> String {
|
private func modeLabel(_ mode: AVAudioSession.Mode) -> String {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .voiceChat: return "voiceChat"
|
case .voiceChat: return "voiceChat"
|
||||||
case .measurement: return "measurement"
|
case .measurement: return "measurement"
|
||||||
|
case .default: return "default"
|
||||||
default: return "other"
|
default: return "other"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func optionsLabel(_ opts: AVAudioSession.CategoryOptions) -> String {
|
||||||
|
var parts: [String] = []
|
||||||
|
if opts.contains(.defaultToSpeaker) { parts.append("defaultToSpeaker") }
|
||||||
|
if opts.contains(.mixWithOthers) { parts.append("mixWithOthers") }
|
||||||
|
if opts.contains(.allowBluetooth) { parts.append("allowBluetooth") }
|
||||||
|
if opts.contains(.allowBluetoothA2DP) { parts.append("allowBluetoothA2DP") }
|
||||||
|
return parts.joined(separator: ",")
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply stored preferences from UserDefaults. Called at app launch (before any
|
/// Apply stored preferences from UserDefaults. Called at app launch (before any
|
||||||
/// audio session activation).
|
/// audio session activation).
|
||||||
func loadStoredPreferences() {
|
func loadStoredPreferences() {
|
||||||
@@ -302,7 +326,7 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
micMode = mode
|
micMode = mode
|
||||||
savePreferences()
|
savePreferences()
|
||||||
applyConfiguration()
|
applyConfiguration()
|
||||||
updateRawModeWarning()
|
updateWarnings()
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectCaptureChannels(_ channels: CaptureChannels) {
|
func selectCaptureChannels(_ channels: CaptureChannels) {
|
||||||
@@ -313,12 +337,14 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
/// Show a warning when Raw/Studio mode is active and the output route is the speaker
|
/// Update warning indicators for the Settings UI.
|
||||||
/// (echo risk since AEC is off in .measurement mode).
|
private func updateWarnings() {
|
||||||
private func updateRawModeWarning() {
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
||||||
|
// Raw/Studio mode + speaker = echo risk (no AEC in .measurement mode)
|
||||||
showsRawModeSpeakerWarning = (micMode == .raw && outputIsSpeaker)
|
showsRawModeSpeakerWarning = (micMode == .raw && outputIsSpeaker)
|
||||||
|
// Standard mode + A2DP = no hardware AEC (A2DP incompatible with .voiceChat mode)
|
||||||
|
showsA2dpNoAecWarning = (micMode == .standard && bluetoothMode == .builtInMicBtA2dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selected input port object, if any.
|
/// The selected input port object, if any.
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ final class SessionState {
|
|||||||
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
|
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
|
||||||
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
|
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
|
||||||
case .streamStarted:
|
case .streamStarted:
|
||||||
|
// A remote user started a stream — ensure the audio session is active so we can
|
||||||
|
// hear them even if we haven't joined voice ourselves. Previously the session was
|
||||||
|
// only activated when the user pressed Join Voice, so remote audio was silent.
|
||||||
|
if ev.userId != selfUserId {
|
||||||
|
do {
|
||||||
|
try AudioSessionManager.shared.ensureSessionActive()
|
||||||
|
} catch {
|
||||||
|
addActivity("Audio session activate failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
addActivity("Stream started (user \(ev.userId))")
|
addActivity("Stream started (user \(ev.userId))")
|
||||||
case .streamStopped:
|
case .streamStopped:
|
||||||
addActivity("Stream stopped (user \(ev.userId))")
|
addActivity("Stream stopped (user \(ev.userId))")
|
||||||
@@ -184,7 +194,7 @@ final class SessionState {
|
|||||||
|
|
||||||
private func doStartMicStream() {
|
private func doStartMicStream() {
|
||||||
do {
|
do {
|
||||||
try AudioSessionManager.shared.activateForStreaming()
|
try AudioSessionManager.shared.ensureSessionActive()
|
||||||
} catch {
|
} catch {
|
||||||
addActivity("AVAudioSession activate failed: \(error)")
|
addActivity("AVAudioSession activate failed: \(error)")
|
||||||
return
|
return
|
||||||
@@ -213,7 +223,9 @@ final class SessionState {
|
|||||||
}
|
}
|
||||||
voiceState.micActive = false
|
voiceState.micActive = false
|
||||||
voiceState.level = 0
|
voiceState.level = 0
|
||||||
AudioSessionManager.shared.deactivateAfterStreaming()
|
// Do NOT deactivate the AVAudioSession here — the user may still want to hear
|
||||||
|
// remote audio (other people talking). The session is deactivated only when
|
||||||
|
// disconnecting from the server (see AppState.disconnect / .disconnected event).
|
||||||
}
|
}
|
||||||
|
|
||||||
func setMute(_ muted: Bool, deafened: Bool) {
|
func setMute(_ muted: Bool, deafened: Bool) {
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ struct SettingsView: View {
|
|||||||
.accessibilityLabel("Warning: Raw mode with speaker output may cause echo")
|
.accessibilityLabel("Warning: Raw mode with speaker output may cause echo")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if router.showsA2dpNoAecWarning {
|
||||||
|
Label(
|
||||||
|
"A2DP mode — no echo cancellation (hardware AEC unavailable)",
|
||||||
|
systemImage: "info.circle.fill"
|
||||||
|
)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.font(.caption)
|
||||||
|
.accessibilityLabel("Info: A2DP output mode does not support hardware echo cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
// Capture channels: Mono vs Stereo
|
// Capture channels: Mono vs Stereo
|
||||||
Picker("Channels", selection: Binding(
|
Picker("Channels", selection: Binding(
|
||||||
get: { router.captureChannels },
|
get: { router.captureChannels },
|
||||||
|
|||||||
Reference in New Issue
Block a user