diff --git a/.changes/audio-session-defaults b/.changes/audio-session-defaults new file mode 100644 index 000000000..a82462ddf --- /dev/null +++ b/.changes/audio-session-defaults @@ -0,0 +1 @@ +patch type="fixed" "iOS audio session: refined `.playAndRecord` selection; dropped `.mixWithOthers` and `.allowAirPlay`" diff --git a/Docs/audio.md b/Docs/audio.md index 385ae7af4..cd1e9526e 100644 --- a/Docs/audio.md +++ b/Docs/audio.md @@ -36,7 +36,7 @@ changing it while the engine is in use. > Note: If `isAutomaticConfigurationEnabled` is `false`, the SDK does not touch the audio > session, so this setting has no effect. -By default, the SDK deactivates the `AVAudioSession` when both playout and recording are disabled (e.g., after disconnecting from a room). This allows other apps' audio (like Music) to resume. +By default, the SDK deactivates the `AVAudioSession` when both playout and recording are disabled (e.g., after disconnecting from a room). Before deactivating, the category is reset to `.ambient` so the iOS volume rocker returns to the media register, and other apps' audio (like Music) can resume. However, if your app has its own audio features that could be disrupted by deactivating the audio session, you can disable automatic deactivation: @@ -46,6 +46,17 @@ AudioManager.shared.audioSession.isAutomaticDeactivationEnabled = false When set to `false`, the audio session remains active after the LiveKit call ends, preserving your app's audio state. +## Audio session category selection + +When `isAutomaticConfigurationEnabled` is `true`, the SDK picks the audio session category based on whether the local participant is — or might be — publishing audio: + +- **Audience** (server-issued `canPublish: false`, and the app has not enabled `isRecordingAlwaysPreparedMode`): + `.playback` for the entire session. The iOS volume rocker stays on the media register and no microphone permission prompt is required. +- **Publisher** (server-issued mic-publish permission, the app enabled `isRecordingAlwaysPreparedMode`, or the mic has been enabled at any point during the session): + `.playAndRecord` from then on. Once engaged, the category stays `.playAndRecord` even when the mic is muted — the category doesn't churn on every mute toggle. + +The category is reset to `.ambient` when both playout and recording stop (typically after `Room.disconnect()`). + ## Disabling Voice Processing Apple's voice processing is enabled by default, such as echo cancellation and auto-gain control. diff --git a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift index 3d83b85e2..c4bc259ce 100644 --- a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift +++ b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift @@ -64,6 +64,16 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck set { _state.mutate { $0.isSpeakerOutputPreferred = newValue } } } + /// Whether the active local participant has permission to publish microphone + /// audio. Defaults to `true` (optimistic) until permissions arrive from the + /// server. Gates the pre-emptive `.playAndRecord` upgrade when the app sets + /// `isRecordingAlwaysPreparedMode`; once recording actually engages on any + /// path the sticky bit and `isRecordingEnabled` take over and override. + var canPublishMicrophone: Bool { + get { _state.canPublishMicrophone } + set { _state.mutate { $0.canPublishMicrophone = newValue } } + } + struct State { var next: (any AudioEngineObserver)? @@ -72,6 +82,14 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck var isSpeakerOutputPreferred: Bool = true var sessionRequirements: [UUID: SessionRequirement] = [:] + + // Sticky: true once recording engaged this session, cleared on the empty edge. + // Keeps `.playAndRecord` across mute toggles instead of churning the category + // on every requirement change. + var hasRecorded: Bool = false + + // Tracks the active local participant's mic publish permission. See accessor. + var canPublishMicrophone: Bool = true } let _state = StateSync(State()) @@ -132,6 +150,15 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck let oldState = $0 block(&$0.sessionRequirements) guard $0.sessionRequirements != oldState.sessionRequirements else { return } + + // Maintain the sticky `hasRecorded` bit. + if $0.isRecordingEnabled { + $0.hasRecorded = true + } else if !$0.isPlayoutEnabled { + // Empty edge — reset for the next session. + $0.hasRecorded = false + } + do { try configureIfNeeded(oldState: oldState, newState: $0) } catch { @@ -167,7 +194,10 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck if (!newState.isPlayoutEnabled && !newState.isRecordingEnabled) && (oldState.isPlayoutEnabled || oldState.isRecordingEnabled) { if newState.isAutomaticDeactivationEnabled { do { - log("AudioSession deactivating...") + log("AudioSession resetting to ambient and deactivating...") + // Restore the media volume register; rocker stays on ringer/call otherwise. + let idle = AudioSessionConfiguration.ambient + try session.setCategory(idle.category, mode: idle.mode, options: idle.categoryOptions) try session.setActive(false, options: .notifyOthersOnDeactivation) } catch { log("AudioSession failed to deactivate with error: \(error)", .error) @@ -177,9 +207,7 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck log("AudioSession deactivation skipped...") } } else if newState.isRecordingEnabled || newState.isPlayoutEnabled { - // Configure and activate the session with the appropriate category - let playAndRecord: AudioSessionConfiguration = newState.isSpeakerOutputPreferred ? .playAndRecordSpeaker : .playAndRecordReceiver - let config: AudioSessionConfiguration = newState.isRecordingEnabled ? playAndRecord : .playback + let config = selectConfiguration(state: newState) do { log("AudioSession configuring category to: \(config.category)") @@ -212,6 +240,25 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck } } + /// Picks the audio session configuration for the current state. + /// + /// `.playAndRecord` is selected when any signal indicates the user is or + /// will be publishing; otherwise `.playback` (pure listener): + /// - `isRecordingEnabled`: a track or external acquirer needs recording now. + /// - `hasRecorded`: sticky — keeps `.playAndRecord` across mute toggles. + /// - `wantsToPublish`: either signal of publishing intent — + /// server-issued mic permission (`canPublishMicrophone`) or the + /// app-declared `isRecordingAlwaysPreparedMode`. + private func selectConfiguration(state: State) -> AudioSessionConfiguration { + let wantsToPublish = state.canPublishMicrophone || AudioManager.shared.isRecordingAlwaysPreparedMode + let needsRecord = state.isRecordingEnabled || state.hasRecorded || wantsToPublish + let config: AudioSessionConfiguration = needsRecord + ? (state.isSpeakerOutputPreferred ? .playAndRecordSpeaker : .playAndRecordReceiver) + : .playback + log("selectConfiguration: recording=\(state.isRecordingEnabled) hasRecorded=\(state.hasRecorded) canPublishMic=\(state.canPublishMicrophone) speaker=\(state.isSpeakerOutputPreferred) → \(config.category)") + return config + } + // MARK: - AudioEngineObserver public func engineWillEnable(_ engine: AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) -> Int { diff --git a/Sources/LiveKit/Participant/LocalParticipant.swift b/Sources/LiveKit/Participant/LocalParticipant.swift index 146fda51b..fc43b7df0 100644 --- a/Sources/LiveKit/Participant/LocalParticipant.swift +++ b/Sources/LiveKit/Participant/LocalParticipant.swift @@ -235,6 +235,13 @@ public class LocalParticipant: Participant, @unchecked Sendable { } } + #if os(iOS) || os(visionOS) || os(tvOS) + // Mirror the mic-publish permission to the audio session so it can pick + // `.playback` for audience-only participants without waiting for a publish + // attempt to fail. + AudioManager.shared.audioSession.canPublishMicrophone = canPublish(source: .microphone) + #endif + return didUpdate } @@ -748,14 +755,21 @@ extension LocalParticipant { } } - private func checkPermissions(toPublish track: LocalTrack) throws { - guard permissions.canPublish else { - throw LiveKitError(.insufficientPermissions, message: "Participant does not have permission to publish") - } - + /// Whether the participant currently has permission to publish a track from + /// the given source. Combines the top-level `canPublish` grant with the + /// per-source restriction in `canPublishSources` (empty = no restriction). + private func canPublish(source: Track.Source) -> Bool { + guard permissions.canPublish else { return false } let sources = permissions.canPublishSources - if !sources.isEmpty, !sources.contains(track.source.rawValue) { - throw LiveKitError(.insufficientPermissions, message: "Participant does not have permission to publish tracks from this source") + return sources.isEmpty || sources.contains(source.rawValue) + } + + private func checkPermissions(toPublish track: LocalTrack) throws { + guard canPublish(source: track.source) else { + let message = permissions.canPublish + ? "Participant does not have permission to publish tracks from this source" + : "Participant does not have permission to publish" + throw LiveKitError(.insufficientPermissions, message: message) } } } diff --git a/Sources/LiveKit/Types/AudioSessionConfiguration.swift b/Sources/LiveKit/Types/AudioSessionConfiguration.swift index 3e5321ea0..a380abd40 100644 --- a/Sources/LiveKit/Types/AudioSessionConfiguration.swift +++ b/Sources/LiveKit/Types/AudioSessionConfiguration.swift @@ -28,14 +28,27 @@ public extension AudioSessionConfiguration { categoryOptions: [], mode: .default) + static let ambient = AudioSessionConfiguration(category: .ambient, + categoryOptions: [], + mode: .default) + static let playback = AudioSessionConfiguration(category: .playback, categoryOptions: [.mixWithOthers], mode: .spokenAudio) + // `.mixWithOthers` is removed from `.playAndRecord`: + // - it is a known cause of echo when other apps share the audio device + // during a real-time call; + // - our WebRTC ADM has a retry loop working around -66637 + // (kAudioUnitErr_Initialized) on interruption-end recovery when this + // option is active (related symptom: #1011). + // Intentionally kept on `.playback` (listener) where mixing is correct. + // `.allowAirPlay` is redundant under `.voiceChat`/`.videoChat` per QA1803: + // https://developer.apple.com/library/archive/qa/qa1803/_index.html #if swift(>=6.2) - private static let playAndRecordOptions: AVAudioSession.CategoryOptions = [.mixWithOthers, .allowBluetoothHFP, .allowBluetoothA2DP, .allowAirPlay] + private static let playAndRecordOptions: AVAudioSession.CategoryOptions = [.allowBluetoothHFP, .allowBluetoothA2DP] #else - private static let playAndRecordOptions: AVAudioSession.CategoryOptions = [.mixWithOthers, .allowBluetooth, .allowBluetoothA2DP, .allowAirPlay] + private static let playAndRecordOptions: AVAudioSession.CategoryOptions = [.allowBluetooth, .allowBluetoothA2DP] #endif static let playAndRecordSpeaker = AudioSessionConfiguration(category: .playAndRecord,