Skip to content

[Enhancement]Improved audio session management #906

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Aug 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d8909de
[Enhancement]Expand applicationState with `.unknown`
ipavlidakis Jul 30, 2025
0225e6d
WIP
ipavlidakis Jul 30, 2025
2120501
More updages
ipavlidakis Jul 30, 2025
e52db2a
Expand logging
ipavlidakis Jul 30, 2025
473cc97
fix streamvideo audiosession configuration
ipavlidakis Jul 30, 2025
5cac353
update stateadapter
ipavlidakis Jul 30, 2025
a193ffe
Update execution order
ipavlidakis Jul 30, 2025
f4b611d
Do not activate session on callkit report
ipavlidakis Jul 31, 2025
7bcd659
Only complete activation from callkit when the app isn't in foreground
ipavlidakis Jul 31, 2025
629155b
Simplified flow
ipavlidakis Jul 31, 2025
412ab9b
trace audiosession events
ipavlidakis Jul 31, 2025
2a8169c
add log
ipavlidakis Jul 31, 2025
9dfd1b2
remove ringingCall
ipavlidakis Jul 31, 2025
8624d6a
update setPrefersNoInterruptionsFromSystemAlerts
ipavlidakis Jul 31, 2025
45e3693
Move activation/deactivation to mainthread and check the callCid of t…
ipavlidakis Jul 31, 2025
71456d3
Add comments
ipavlidakis Jul 31, 2025
d5c90bf
RTCAudioStore implementation
ipavlidakis Aug 5, 2025
dbd3395
Add comments and clean up
ipavlidakis Aug 6, 2025
432d38f
Fix tests
ipavlidakis Aug 6, 2025
17f69b8
Add tests
ipavlidakis Aug 6, 2025
c660c0f
Add RTCAudioStore tests
ipavlidakis Aug 6, 2025
cacb2e9
Add stages tests
ipavlidakis Aug 6, 2025
bf8c7ed
Fix Xcode 15 compilation error
ipavlidakis Aug 7, 2025
9b20956
Add more tests
ipavlidakis Aug 7, 2025
3a21a9d
Implement handling for audioOutputOn
ipavlidakis Aug 7, 2025
07e9d04
Add tests for audioOutputOn handling
ipavlidakis Aug 7, 2025
123129d
Fix scenario where disabling output with CallKit
ipavlidakis Aug 7, 2025
6d99221
Fix memory leak
ipavlidakis Aug 7, 2025
95b8d12
Update changelog
ipavlidakis Aug 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### 🔄 Changed
### 🐞 Fixed
- AudioSession management issues that were causing audio not being recorded during calls. [#906](https://github.com/GetStream/stream-video-swift/pull/906)

# [1.29.1](https://github.com/GetStream/stream-video-swift/releases/tag/1.29.1)
_July 25, 2025_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ struct DemoMoreControlsViewModifier: ViewModifier {
)
}

DemoMoreControlListButtonView(
action: { viewModel.toggleAudioOutput() },
label: viewModel.callSettings.audioOutputOn ? "Disable audio output" : "Enable audio output"
) {
Image(
systemName: viewModel.callSettings.audioOutputOn
? "speaker.fill"
: "speaker.slash"
)
}

DemoTranscriptionAndClosedCaptionsButtonView(viewModel: viewModel)

DemoMoreThermalStateButtonView()
Expand Down
39 changes: 21 additions & 18 deletions Sources/StreamVideo/Call.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,24 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
notify: Bool = false,
callSettings: CallSettings? = nil
) async throws -> JoinCallResponse {
/// Determines the source from which the join action was initiated.
///
/// This block checks if the `joinSource` has already been set in the current
/// call state. If not, it assigns `.inApp` as the default join source,
/// indicating the call was joined from within the app UI. The resolved
/// `JoinSource` value is then used to record how the call was joined,
/// enabling analytics and behavioral branching based on entry point.
let joinSource = await {
if let joinSource = await state.joinSource {
return joinSource
} else {
return await Task { @MainActor in
state.joinSource = .inApp
return .inApp
}.value
}
}()

let result: Any? = stateMachine.withLock { currentStage, transitionHandler in
if
currentStage.id == .joined,
Expand Down Expand Up @@ -194,6 +212,7 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
options: options,
ring: ring,
notify: notify,
source: joinSource,
deliverySubject: deliverySubject
)
)
Expand Down Expand Up @@ -1371,8 +1390,8 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
/// - Parameter policy: A conforming `AudioSessionPolicy` that defines
/// the audio session configuration to be applied.
/// - Throws: An error if the update fails.
public func updateAudioSessionPolicy(_ policy: AudioSessionPolicy) async throws {
try await callController.updateAudioSessionPolicy(policy)
public func updateAudioSessionPolicy(_ policy: AudioSessionPolicy) async {
await callController.updateAudioSessionPolicy(policy)
}

/// Adds a proximity policy to manage device proximity behavior during the call.
Expand Down Expand Up @@ -1473,22 +1492,6 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
)
}

// MARK: - CallKit

/// Notifies the `Call` instance that CallKit has activated the system audio
/// session.
///
/// This method should be called when the system activates the `AVAudioSession`
/// as a result of an incoming or outgoing CallKit-managed call. It allows the
/// call to update the provided CallKit AVAudioSession based on the internal CallSettings.
///
/// - Parameter audioSession: The active `AVAudioSession` instance provided by
/// CallKit.
/// - Throws: An error if the call controller fails to handle the activation.
internal func callKitActivated(_ audioSession: AVAudioSessionProtocol) throws {
try callController.callKitActivated(audioSession)
}

internal func didPerform(_ action: WebRTCTrace.CallKitAction) {
Task(disposableBag: disposableBag) { [weak callController] in
await callController?.didPerform(action)
Expand Down
46 changes: 30 additions & 16 deletions Sources/StreamVideo/CallKit/CallKitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
@Injected(\.callCache) private var callCache
@Injected(\.uuidFactory) private var uuidFactory
@Injected(\.currentDevice) private var currentDevice
@Injected(\.audioStore) private var audioStore
private let disposableBag = DisposableBag()

/// Represents a call that is being managed by the service.
Expand Down Expand Up @@ -95,6 +96,13 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
private var callEndedNotificationCancellable: AnyCancellable?
private var ringingTimerCancellable: AnyCancellable?

/// A reducer responsible for handling audio session changes triggered by CallKit.
///
/// The `callKitAudioReducer` manages updates to the audio session state in
/// response to CallKit events, ensuring proper activation and deactivation
/// of the audio system when calls are handled through CallKit.
private lazy var callKitAudioReducer = CallKitAudioSessionReducer(store: audioStore)

/// Initializes the `CallKitService` instance.
override public init() {
super.init()
Expand Down Expand Up @@ -164,6 +172,7 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
return
}
do {

if streamVideo.state.connection != .connected {
let result = await Task(disposableBag: disposableBag) { [weak self] in
try await self?.streamVideo?.connect()
Expand Down Expand Up @@ -392,17 +401,11 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
subsystems: .callKit
)

if
let active,
let call = callEntry(for: active)?.call {
call.didPerform(.didActivateAudioSession)

do {
try call.callKitActivated(audioSession)
} catch {
log.error(error, subsystems: .callKit)
}
}
/// Activates the audio session for CallKit. This line notifies the audio store
/// to activate the provided AVAudioSession, ensuring that the app's audio
/// routing and configuration are correctly handled when CallKit takes control
/// of the audio session during a call.
audioStore.dispatch(.callKit(.activate(audioSession)))
}

public func provider(
Expand All @@ -421,11 +424,11 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
""",
subsystems: .callKit
)
if
let active,
let call = callEntry(for: active)?.call {
call.didPerform(.didDeactivateAudioSession)
}

/// Notifies the audio store to deactivate the provided AVAudioSession.
/// This ensures that when CallKit relinquishes control of the audio session,
/// the app's audio routing and configuration are updated appropriately.
audioStore.dispatch(.callKit(.deactivate(audioSession)))
}

open func provider(
Expand Down Expand Up @@ -460,6 +463,10 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
}

do {
/// Sets the join source to `.callKit` to indicate that the call was
/// joined via CallKit. This helps with audioSession management.
callToJoinEntry.call.state.joinSource = .callKit

try await callToJoinEntry.call.join(callSettings: callSettings)
action.fulfill()
} catch {
Expand Down Expand Up @@ -640,9 +647,16 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
/// A method that's being called every time the StreamVideo instance is getting updated.
/// - Parameter streamVideo: The new StreamVideo instance (nil if none)
open func didUpdate(_ streamVideo: StreamVideo?) {
if streamVideo != nil {
audioStore.add(callKitAudioReducer)
} else {
audioStore.remove(callKitAudioReducer)
}

guard currentDevice.deviceType != .simulator else {
return
}

subscribeToCallEvents()
}

Expand Down
9 changes: 8 additions & 1 deletion Sources/StreamVideo/CallState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,14 @@ public class CallState: ObservableObject {
}
}
}


/// Describes the source from which the join action was triggered for this call.
///
/// Use this property to determine whether the current call was joined from
/// the app's UI or via a system-level integration such as CallKit. This can
/// help customize logic, analytics, and UI based on how the call was started.
var joinSource: JoinSource?

private var localCallSettingsUpdate = false
private var durationCancellable: AnyCancellable?
private nonisolated let disposableBag = DisposableBag()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ extension Call.StateMachine.Stage {
callSettings: input.callSettings,
options: input.options,
ring: input.ring,
notify: input.notify
notify: input.notify,
source: input.source
)

if let callSettings = input.callSettings {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ extension Call.StateMachine {
var options: CreateCallOptions?
var ring: Bool
var notify: Bool
var source: JoinSource
var deliverySubject: PassthroughSubject<JoinCallResponse, Error>

var currentNumberOfRetries = 0
Expand Down
38 changes: 20 additions & 18 deletions Sources/StreamVideo/Controllers/CallController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,24 +105,29 @@ class CallController: @unchecked Sendable {
.sinkTask(storeIn: disposableBag) { [weak self] in await self?.didFetch($0) }
}

/// Joins a call with the provided information.
/// Joins a call with the provided information and join source.
///
/// - Parameters:
/// - callType: the type of the call
/// - callId: the id of the call
/// - callSettings: the current call settings
/// - videoOptions: configuration options about the video
/// - options: create call options
/// - migratingFrom: if SFU migration is being performed
/// - ring: whether ringing events should be handled
/// - notify: whether uses should be notified about the call
/// - Returns: a newly created `Call`.
/// - callType: The type of the call.
/// - callId: The id of the call.
/// - callSettings: The current call settings.
/// - videoOptions: Configuration options about the video.
/// - options: Create call options.
/// - migratingFrom: If SFU migration is being performed.
/// - ring: Whether ringing events should be handled.
/// - notify: Whether users should be notified about the call.
/// - source: Describes the source from which the join action was triggered.
/// Use this to indicate if the call was joined from in-app UI or
/// via CallKit.
/// - Returns: A newly created `JoinCallResponse`.
@discardableResult
func joinCall(
create: Bool = true,
callSettings: CallSettings?,
options: CreateCallOptions? = nil,
ring: Bool = false,
notify: Bool = false
notify: Bool = false,
source: JoinSource
) async throws -> JoinCallResponse {
joinCallResponseSubject = .init(nil)

Expand All @@ -131,7 +136,8 @@ class CallController: @unchecked Sendable {
callSettings: callSettings,
options: options,
ring: ring,
notify: notify
notify: notify,
source: source
)

guard
Expand Down Expand Up @@ -479,8 +485,8 @@ class CallController: @unchecked Sendable {
///
/// - Parameter policy: The audio session policy to apply
/// - Throws: An error if the policy update fails
func updateAudioSessionPolicy(_ policy: AudioSessionPolicy) async throws {
try await webRTCCoordinator.updateAudioSessionPolicy(policy)
func updateAudioSessionPolicy(_ policy: AudioSessionPolicy) async {
await webRTCCoordinator.updateAudioSessionPolicy(policy)
}

/// Sets up observation of WebRTC state changes.
Expand All @@ -501,10 +507,6 @@ class CallController: @unchecked Sendable {
.sink { [weak self] in self?.webRTCClientDidUpdateStage($0) }
}

internal func callKitActivated(_ audioSession: AVAudioSessionProtocol) throws {
try webRTCCoordinator.callKitActivated(audioSession)
}

// MARK: - Client Capabilities

func enableClientCapabilities(_ capabilities: Set<ClientCapability>) async {
Expand Down
19 changes: 19 additions & 0 deletions Sources/StreamVideo/Models/JoinSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

/// An enumeration that describes the source from which a call was joined.
///
/// Use `JoinSource` to indicate whether the join action originated from within
/// the app's own UI or through a system-level interface such as CallKit.
/// This helps distinguish the user's entry point and can be used to customize
/// behavior or analytics based on how the call was initiated.
enum JoinSource {
/// Indicates that the call was joined from within the app's UI.
case inApp

/// Indicates that the call was joined via CallKit integration.
case callKit
}
1 change: 1 addition & 0 deletions Sources/StreamVideo/StreamVideo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class StreamVideo: ObservableObject, @unchecked Sendable {

@Injected(\.callCache) private var callCache
@Injected(\.screenProperties) private var screenProperties
@Injected(\.audioStore) private var audioStore

private enum DisposableKey: String { case ringEventReceived }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
private let processingQueue = OperationQueue(maxConcurrentOperationCount: 1)

@Injected(\.activeCallProvider) private var activeCallProvider
@Injected(\.activeCallAudioSession) private var activeCallAudioSession
@Injected(\.audioStore) private var audioStore

/// The builder used to create the AVAudioRecorder instance.
let audioRecorderBuilder: AVAudioRecorderBuilder
Expand All @@ -38,7 +38,6 @@ open class StreamCallAudioRecorder: @unchecked Sendable {

@Atomic private(set) var isRecording: Bool = false {
willSet {
activeCallAudioSession?.isRecording = newValue
_isRecordingSubject.send(newValue)
}
}
Expand Down Expand Up @@ -194,7 +193,7 @@ open class StreamCallAudioRecorder: @unchecked Sendable {

private func setUpAudioCaptureIfRequired() async throws -> AVAudioRecorder {
guard
await activeCallAudioSession?.requestRecordPermission() == true
await audioStore.requestRecordPermission() == true
else {
throw ClientError("🎙️Permission denied.")
}
Expand All @@ -219,11 +218,8 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
}

private func deferSessionActivation() async {
guard let activeCallAudioSession else {
return
}
_ = try? await activeCallAudioSession
.$category
_ = try? await audioStore
.publisher(\.category)
.filter { $0 == .playAndRecord }
.nextValue(timeout: 1)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
import AVFoundation

/// Represents the audio session configuration.
public struct AudioSessionConfiguration: ReflectiveStringConvertible,
Equatable {
public struct AudioSessionConfiguration: ReflectiveStringConvertible, Equatable, Sendable {
var isActive: Bool
/// The audio session category.
var category: AVAudioSession.Category
/// The audio session mode.
Expand All @@ -18,7 +18,8 @@ public struct AudioSessionConfiguration: ReflectiveStringConvertible,

/// Compares two `AudioSessionConfiguration` instances for equality.
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.category == rhs.category &&
lhs.isActive == rhs.isActive &&
lhs.category == rhs.category &&
lhs.mode == rhs.mode &&
lhs.options.rawValue == rhs.options.rawValue &&
lhs.overrideOutputAudioPort?.rawValue ==
Expand Down
Loading