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:
2026-06-19 13:46:20 +02:00
parent ab973940df
commit 3e80af2f3f
5 changed files with 119 additions and 32 deletions

View File

@@ -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.