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

@@ -89,7 +89,9 @@ final class AppState {
}
func disconnect() {
session?.stopMicStream()
session?.client.disconnect()
AudioSessionManager.shared.deactivateSession()
session = nil
connectingClient?.disconnect()
connectingClient = nil
@@ -163,7 +165,10 @@ final class AppState {
}
case .disconnected:
if session == nil { cancelConnect() }
else { session = nil; isConnecting = false }
else {
AudioSessionManager.shared.deactivateSession()
session = nil; isConnecting = false
}
case .error:
connectStatus = ev.text ?? "Unknown error"
if session == nil { isConnecting = false }

View File

@@ -10,6 +10,15 @@ final class AudioSessionManager {
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() {
// Load stored audio routing preferences and apply them before any audio session
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
@@ -26,17 +35,34 @@ final class AudioSessionManager {
name: AVAudioSession.routeChangeNotification, object: nil)
}
func activateForStreaming() throws {
// Re-apply the routing configuration before activating, in case the user changed
// settings since the last apply. The core (miniaudio) will open whatever route
// AVAudioSession has established.
/// 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()
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,
options: .notifyOthersOnDeactivation)
isSessionActive = false
logger.info("session deactivated")
}
@objc private func handleInterruption(_ notification: Notification) {
@@ -47,13 +73,21 @@ final class AudioSessionManager {
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) {
try? AVAudioSession.sharedInstance().setActive(true)
client?.audioResume()
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
}

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.

View File

@@ -100,6 +100,16 @@ final class SessionState {
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
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))")
case .streamStopped:
addActivity("Stream stopped (user \(ev.userId))")
@@ -184,7 +194,7 @@ final class SessionState {
private func doStartMicStream() {
do {
try AudioSessionManager.shared.activateForStreaming()
try AudioSessionManager.shared.ensureSessionActive()
} catch {
addActivity("AVAudioSession activate failed: \(error)")
return
@@ -213,7 +223,9 @@ final class SessionState {
}
voiceState.micActive = false
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) {

View File

@@ -83,6 +83,16 @@ struct SettingsView: View {
.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
Picker("Channels", selection: Binding(
get: { router.captureChannels },