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

@@ -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) {