From ee06adb81eb02874d61ed429f23583e1c3e1a917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 26 May 2026 10:48:50 +0200 Subject: [PATCH 01/11] fix: reset audio session category to .ambient on the empty edge When both playout and recording stop, reset the category to .ambient before deactivating. Without this, the iOS volume rocker stays on the ringer/call register because the last-active category was .playAndRecord, causing in-call volume to appear to "stick" when the user adjusts the rocker after the call ends. .ambient mixes with other audio so any resumed media (e.g. Music) isn't re-interrupted if the session momentarily reactivates afterwards. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/LiveKit/Audio/AudioSessionEngineObserver.swift | 5 ++++- Sources/LiveKit/Types/AudioSessionConfiguration.swift | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift index 3d83b85e2..41b8ce0f5 100644 --- a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift +++ b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift @@ -167,7 +167,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) diff --git a/Sources/LiveKit/Types/AudioSessionConfiguration.swift b/Sources/LiveKit/Types/AudioSessionConfiguration.swift index 3e5321ea0..81e4ad349 100644 --- a/Sources/LiveKit/Types/AudioSessionConfiguration.swift +++ b/Sources/LiveKit/Types/AudioSessionConfiguration.swift @@ -28,6 +28,10 @@ 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) From 432617ee93a8ff50b14e7db9c2848cfd8ce5554f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 26 May 2026 10:49:11 +0200 Subject: [PATCH 02/11] fix: drop .mixWithOthers and .allowAirPlay from .playAndRecord options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent motivations: - .mixWithOthers is a known cause of echo in real-time communication scenarios where other apps share the audio device — once the option is set, other apps' audio mixes into the capture path and degrades the acoustic echo cancellation reference. - Our WebRTC ADM has a retry loop in the engine restart path working around -66637 (kAudioUnitErr_Initialized) on interruption-end recovery when this option is active. Removing the option from .playAndRecord eliminates the workaround's triggering condition. Related customer symptom: #1011. .allowAirPlay is redundant under both .voiceChat and .videoChat per Apple QA1803 — .voiceChat disallows AirPlay outright, .videoChat auto-allows the mirrored variant. .mixWithOthers is intentionally kept on the .playback config used for listener-only sessions, where mixing with media playback is correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../LiveKit/Types/AudioSessionConfiguration.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/LiveKit/Types/AudioSessionConfiguration.swift b/Sources/LiveKit/Types/AudioSessionConfiguration.swift index 81e4ad349..a380abd40 100644 --- a/Sources/LiveKit/Types/AudioSessionConfiguration.swift +++ b/Sources/LiveKit/Types/AudioSessionConfiguration.swift @@ -36,10 +36,19 @@ public extension AudioSessionConfiguration { 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, From 39a29db97e9267feed25d04fc086ef8b1ed79ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 26 May 2026 11:14:15 +0200 Subject: [PATCH 03/11] refactor: pick audio session category from sticky and permission signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audio session category was re-evaluated on every SessionRequirement change. Muting via setMicrophone(enabled: false) would downgrade from .playAndRecord back to .playback, then unmuting would upgrade again — causing N category switches per session, each one flipping the iOS volume rocker between the media and ringer registers. Selection now flows through `selectConfiguration(state:)` and lands on .playAndRecord when any of the following holds, staying there until both playout and recording stop: - recording is enabled (first setMicrophone(enabled: true) or external acquire(requirement: .recording)) - recording was previously enabled in the same session (sticky bit `hasEverRecorded`, cleared only on the empty edge) - the participant wants to publish: the app declared publishing intent via isRecordingAlwaysPreparedMode AND the local participant has permission to publish a microphone (`canPublishMicrophone`, driven by ParticipantPermissions) Pure audience participants (no permission, never recorded, no current recording requirement) keep .playback for the entire session. The mic publish permission defaults to true here (optimistic) and is corrected once permissions arrive from the server. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Audio/AudioSessionEngineObserver.swift | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift index 41b8ce0f5..14de1b3ef 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 hasEverRecorded: 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 `hasEverRecorded` bit. + if $0.isRecordingEnabled { + $0.hasEverRecorded = true + } else if !$0.isPlayoutEnabled { + // Empty edge — reset for the next session. + $0.hasEverRecorded = false + } + do { try configureIfNeeded(oldState: oldState, newState: $0) } catch { @@ -180,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)") @@ -215,6 +240,22 @@ 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. + /// - `hasEverRecorded`: sticky — keeps `.playAndRecord` across mute toggles. + /// - `wantsToPublish`: the app declared publishing intent via + /// `isRecordingAlwaysPreparedMode` AND the participant has permission + /// to publish a microphone (`canPublishMicrophone`). + private func selectConfiguration(state: State) -> AudioSessionConfiguration { + let wantsToPublish = state.canPublishMicrophone && AudioManager.shared.isRecordingAlwaysPreparedMode + let needsRecord = state.isRecordingEnabled || state.hasEverRecorded || wantsToPublish + guard needsRecord else { return .playback } + return state.isSpeakerOutputPreferred ? .playAndRecordSpeaker : .playAndRecordReceiver + } + // MARK: - AudioEngineObserver public func engineWillEnable(_ engine: AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) -> Int { From 67e9a4e5733fd4d0ff71c48fb2bf3bbadb4909c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 26 May 2026 11:20:27 +0200 Subject: [PATCH 04/11] feat: forward mic-publish permission to the audio session When the local participant's permissions change (server-issued at JoinResponse time, or via a mid-session permission update), forward the derived "can publish microphone" predicate to the audio session so it can pick `.playback` for audience-only participants without waiting for a publish attempt to fail. Extracts a `canPublish(source:)` helper from the existing `checkPermissions(toPublish:)` so the two callers share one predicate definition. The helper combines the top-level `canPublish` grant with the per-source restriction in `canPublishSources` (empty = no restriction). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Participant/LocalParticipant.swift | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) 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) } } } From d4a14072981edc584bf5b9b2de9a528549e7739a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 26 May 2026 11:20:56 +0200 Subject: [PATCH 05/11] chore: drop intent gate from wantsToPublish predicate for now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment out the `&& AudioManager.shared.isRecordingAlwaysPreparedMode` conjunct in selectConfiguration so the pre-emptive .playAndRecord upgrade is purely server (permission) driven. Any participant with mic-publish permission will get .playAndRecord up-front instead of waiting on the app-side intent signal. Trade-off: closer to the "always playAndRecord for permitted users" pattern. Participants who could publish but never do still get the mic permission prompt and ringer volume register on connect — keeping the intent signal commented makes restoration trivial once we validate the desired behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/LiveKit/Audio/AudioSessionEngineObserver.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift index 14de1b3ef..479c20df3 100644 --- a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift +++ b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift @@ -250,7 +250,8 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck /// `isRecordingAlwaysPreparedMode` AND the participant has permission /// to publish a microphone (`canPublishMicrophone`). private func selectConfiguration(state: State) -> AudioSessionConfiguration { - let wantsToPublish = state.canPublishMicrophone && AudioManager.shared.isRecordingAlwaysPreparedMode + // Purely permission-driven for now; intent gate disabled pending validation. + let wantsToPublish = state.canPublishMicrophone // && AudioManager.shared.isRecordingAlwaysPreparedMode let needsRecord = state.isRecordingEnabled || state.hasEverRecorded || wantsToPublish guard needsRecord else { return .playback } return state.isSpeakerOutputPreferred ? .playAndRecordSpeaker : .playAndRecordReceiver From 47334512949615fcc5c68662cb91bb128a1a4f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 26 May 2026 14:42:41 +0200 Subject: [PATCH 06/11] chore: add changeset for iOS audio session defaults Co-Authored-By: Claude Opus 4.7 (1M context) --- .changes/audio-session-defaults | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/audio-session-defaults 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`" From 1da855dcd6d4c4ceba8a9c60726198a0ee8e95da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 26 May 2026 14:44:13 +0200 Subject: [PATCH 07/11] docs: describe audio session category selection and post-call reset Reflects the behavior shipped in this branch: - .ambient reset on the empty edge before deactivating - Listener vs publisher category selection - Sticky .playAndRecord once recording engages - Audience-only participants (canPublish=false) stay on .playback Co-Authored-By: Claude Opus 4.7 (1M context) --- Docs/audio.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Docs/audio.md b/Docs/audio.md index 385ae7af4..162c29f01 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,19 @@ 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 from the participant's role: + +- **Listener** (subscribes to remote audio, never publishes a mic): + `.playback` for the entire session. The iOS volume rocker stays on the media register and no microphone permission prompt is required. +- **Publisher** (mic enabled at any point during the session): + `.playAndRecord` from the first publish onward. Once engaged, the category stays `.playAndRecord` even when the mic is muted — the category doesn't churn on every mute toggle. +- **Audience-only by token** (server-issued `canPublish: false`): + treated as listener for the whole session; never upgrades to `.playAndRecord` even if the app calls `setRecordingAlwaysPreparedMode`. + +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. From 6f76538ac0f33913dbc94cbfaac6e02f9f2f6247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 26 May 2026 14:50:45 +0200 Subject: [PATCH 08/11] chore: log selectConfiguration inputs and chosen category at debug Aids diagnosing why a particular AVAudioSession category was picked without attaching a debugger. Logs the three input signals (current recording state, sticky bit, mic-publish permission) plus the speaker preference and the resulting category. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/LiveKit/Audio/AudioSessionEngineObserver.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift index 479c20df3..c5f1ea8bb 100644 --- a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift +++ b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift @@ -253,8 +253,11 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck // Purely permission-driven for now; intent gate disabled pending validation. let wantsToPublish = state.canPublishMicrophone // && AudioManager.shared.isRecordingAlwaysPreparedMode let needsRecord = state.isRecordingEnabled || state.hasEverRecorded || wantsToPublish - guard needsRecord else { return .playback } - return state.isSpeakerOutputPreferred ? .playAndRecordSpeaker : .playAndRecordReceiver + let config: AudioSessionConfiguration = needsRecord + ? (state.isSpeakerOutputPreferred ? .playAndRecordSpeaker : .playAndRecordReceiver) + : .playback + log("selectConfiguration: recording=\(state.isRecordingEnabled) hasEverRecorded=\(state.hasEverRecorded) canPublishMic=\(state.canPublishMicrophone) speaker=\(state.isSpeakerOutputPreferred) → \(config.category)") + return config } // MARK: - AudioEngineObserver From 9f081ef7a7d41ec1ecf2c11c25fdb957cef52c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 27 May 2026 08:56:55 +0200 Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20rename=20hasEverRecorded=20?= =?UTF-8?q?=E2=86=92=20hasRecorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../LiveKit/Audio/AudioSessionEngineObserver.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift index c5f1ea8bb..1583ffebc 100644 --- a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift +++ b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift @@ -86,7 +86,7 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck // 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 hasEverRecorded: Bool = false + var hasRecorded: Bool = false // Tracks the active local participant's mic publish permission. See accessor. var canPublishMicrophone: Bool = true @@ -151,12 +151,12 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck block(&$0.sessionRequirements) guard $0.sessionRequirements != oldState.sessionRequirements else { return } - // Maintain the sticky `hasEverRecorded` bit. + // Maintain the sticky `hasRecorded` bit. if $0.isRecordingEnabled { - $0.hasEverRecorded = true + $0.hasRecorded = true } else if !$0.isPlayoutEnabled { // Empty edge — reset for the next session. - $0.hasEverRecorded = false + $0.hasRecorded = false } do { @@ -245,18 +245,18 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck /// `.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. - /// - `hasEverRecorded`: sticky — keeps `.playAndRecord` across mute toggles. + /// - `hasRecorded`: sticky — keeps `.playAndRecord` across mute toggles. /// - `wantsToPublish`: the app declared publishing intent via /// `isRecordingAlwaysPreparedMode` AND the participant has permission /// to publish a microphone (`canPublishMicrophone`). private func selectConfiguration(state: State) -> AudioSessionConfiguration { // Purely permission-driven for now; intent gate disabled pending validation. let wantsToPublish = state.canPublishMicrophone // && AudioManager.shared.isRecordingAlwaysPreparedMode - let needsRecord = state.isRecordingEnabled || state.hasEverRecorded || wantsToPublish + let needsRecord = state.isRecordingEnabled || state.hasRecorded || wantsToPublish let config: AudioSessionConfiguration = needsRecord ? (state.isSpeakerOutputPreferred ? .playAndRecordSpeaker : .playAndRecordReceiver) : .playback - log("selectConfiguration: recording=\(state.isRecordingEnabled) hasEverRecorded=\(state.hasEverRecorded) canPublishMic=\(state.canPublishMicrophone) speaker=\(state.isSpeakerOutputPreferred) → \(config.category)") + log("selectConfiguration: recording=\(state.isRecordingEnabled) hasRecorded=\(state.hasRecorded) canPublishMic=\(state.canPublishMicrophone) speaker=\(state.isSpeakerOutputPreferred) → \(config.category)") return config } From 5f78fe7a87206b6a4b2b4d4c8cd52475ff1c4ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 27 May 2026 08:55:09 +0200 Subject: [PATCH 10/11] refactor: treat isRecordingAlwaysPreparedMode as a publisher hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wantsToPublish now fires when either signal indicates publishing intent: server-issued mic permission OR the app-declared isRecordingAlwaysPreparedMode flag. Either alone is sufficient to pre-empt to .playAndRecord. In practice the alwaysPrepared path already triggers engine recording via the ADM (which sets isRecordingEnabled, then the sticky bit), so the additional disjunct rarely fires standalone — but it makes the publisher-hint role of the flag explicit in the predicate rather than buried in a comment. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/LiveKit/Audio/AudioSessionEngineObserver.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift index 1583ffebc..c4bc259ce 100644 --- a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift +++ b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift @@ -246,12 +246,11 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck /// will be publishing; otherwise `.playback` (pure listener): /// - `isRecordingEnabled`: a track or external acquirer needs recording now. /// - `hasRecorded`: sticky — keeps `.playAndRecord` across mute toggles. - /// - `wantsToPublish`: the app declared publishing intent via - /// `isRecordingAlwaysPreparedMode` AND the participant has permission - /// to publish a microphone (`canPublishMicrophone`). + /// - `wantsToPublish`: either signal of publishing intent — + /// server-issued mic permission (`canPublishMicrophone`) or the + /// app-declared `isRecordingAlwaysPreparedMode`. private func selectConfiguration(state: State) -> AudioSessionConfiguration { - // Purely permission-driven for now; intent gate disabled pending validation. - let wantsToPublish = state.canPublishMicrophone // && AudioManager.shared.isRecordingAlwaysPreparedMode + let wantsToPublish = state.canPublishMicrophone || AudioManager.shared.isRecordingAlwaysPreparedMode let needsRecord = state.isRecordingEnabled || state.hasRecorded || wantsToPublish let config: AudioSessionConfiguration = needsRecord ? (state.isSpeakerOutputPreferred ? .playAndRecordSpeaker : .playAndRecordReceiver) From d2316365d344b3c08d2d03907c32bd5f3448c204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 27 May 2026 08:59:11 +0200 Subject: [PATCH 11/11] docs: reflect publisher-hint OR in category selection Updates the bullets to match the predicate after the isRecordingAlwaysPreparedMode hint was OR'd with canPublishMicrophone. Audience-only is now defined by absence of both signals, and the Publisher bullet enumerates all three entry points. Co-Authored-By: Claude Opus 4.7 (1M context) --- Docs/audio.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Docs/audio.md b/Docs/audio.md index 162c29f01..cd1e9526e 100644 --- a/Docs/audio.md +++ b/Docs/audio.md @@ -48,14 +48,12 @@ When set to `false`, the audio session remains active after the LiveKit call end ## Audio session category selection -When `isAutomaticConfigurationEnabled` is `true`, the SDK picks the audio session category from the participant's role: +When `isAutomaticConfigurationEnabled` is `true`, the SDK picks the audio session category based on whether the local participant is — or might be — publishing audio: -- **Listener** (subscribes to remote audio, never publishes a mic): +- **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** (mic enabled at any point during the session): - `.playAndRecord` from the first publish onward. Once engaged, the category stays `.playAndRecord` even when the mic is muted — the category doesn't churn on every mute toggle. -- **Audience-only by token** (server-issued `canPublish: false`): - treated as listener for the whole session; never upgrades to `.playAndRecord` even if the app calls `setRecordingAlwaysPreparedMode`. +- **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()`).