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:
@@ -48,6 +48,7 @@ final class IOSAudioRouter: ObservableObject {
|
||||
@Published var selectedDataSourceId: String?
|
||||
@Published var selectedPolarPattern: String?
|
||||
@Published var showsRawModeSpeakerWarning: Bool = false
|
||||
@Published var showsA2dpNoAecWarning: Bool = false
|
||||
|
||||
enum BluetoothMode: String, CaseIterable, Identifiable {
|
||||
case btHfpVoice = "BT HFP Voice"
|
||||
@@ -134,7 +135,7 @@ final class IOSAudioRouter: ObservableObject {
|
||||
selectedPolarPattern = currentPolarPattern
|
||||
}
|
||||
|
||||
updateRawModeWarning()
|
||||
updateWarnings()
|
||||
}
|
||||
|
||||
// MARK: - Apply configuration
|
||||
@@ -167,24 +168,33 @@ final class IOSAudioRouter: ObservableObject {
|
||||
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
|
||||
switch micMode {
|
||||
case .standard:
|
||||
mode = .voiceChat // AEC/AGC/HPF on
|
||||
case .raw:
|
||||
switch (micMode, bluetoothMode) {
|
||||
case (.standard, .builtInMicBtA2dp):
|
||||
mode = .default // A2DP + hardware AEC = incompatible
|
||||
case (.standard, _):
|
||||
mode = .voiceChat // HFP or speaker: hardware AEC works
|
||||
case (.raw, _):
|
||||
mode = .measurement // all processing off
|
||||
}
|
||||
|
||||
do {
|
||||
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 {
|
||||
logger.error("setCategory failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// 3. Set preferred input port.
|
||||
if let portId = selectedInputPortId,
|
||||
// 3. Set preferred input port (skip if "Default" — empty/nil ID means use system default).
|
||||
if let portId = selectedInputPortId, !portId.isEmpty,
|
||||
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
||||
do {
|
||||
try session.setPreferredInput(port)
|
||||
@@ -194,7 +204,7 @@ final class IOSAudioRouter: ObservableObject {
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
do {
|
||||
try port.setPreferredDataSource(dataSource)
|
||||
@@ -204,7 +214,7 @@ final class IOSAudioRouter: ObservableObject {
|
||||
}
|
||||
|
||||
// 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)
|
||||
do {
|
||||
try dataSource.setPreferredPolarPattern(pattern)
|
||||
@@ -216,25 +226,39 @@ final class IOSAudioRouter: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Set preferred input number of channels (stereo capture).
|
||||
do {
|
||||
try session.setPreferredInputNumberOfChannels(Int(captureChannels.channelCount))
|
||||
logger.info("setPreferredInputNumberOfChannels ok — \(self.captureChannels.rawValue)")
|
||||
} catch {
|
||||
logger.error("setPreferredInputNumberOfChannels failed: \(error.localizedDescription)")
|
||||
// 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 {
|
||||
try session.setPreferredInputNumberOfChannels(2)
|
||||
logger.info("setPreferredInputNumberOfChannels ok — 2 (stereo)")
|
||||
} catch {
|
||||
logger.error("setPreferredInputNumberOfChannels failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
updateRawModeWarning()
|
||||
updateWarnings()
|
||||
}
|
||||
|
||||
private func modeLabel(_ mode: AVAudioSession.Mode) -> String {
|
||||
switch mode {
|
||||
case .voiceChat: return "voiceChat"
|
||||
case .measurement: return "measurement"
|
||||
case .default: return "default"
|
||||
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
|
||||
/// audio session activation).
|
||||
func loadStoredPreferences() {
|
||||
@@ -302,7 +326,7 @@ final class IOSAudioRouter: ObservableObject {
|
||||
micMode = mode
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
updateRawModeWarning()
|
||||
updateWarnings()
|
||||
}
|
||||
|
||||
func selectCaptureChannels(_ channels: CaptureChannels) {
|
||||
@@ -313,12 +337,14 @@ final class IOSAudioRouter: ObservableObject {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Show a warning when Raw/Studio mode is active and the output route is the speaker
|
||||
/// (echo risk since AEC is off in .measurement mode).
|
||||
private func updateRawModeWarning() {
|
||||
/// Update warning indicators for the Settings UI.
|
||||
private func updateWarnings() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
||||
// Raw/Studio mode + speaker = echo risk (no AEC in .measurement mode)
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user