Skip to content

Commit 0e7b406

Browse files
authored
[Enhancement]Improve AudioSession management (#639)
1 parent 0fd1452 commit 0e7b406

File tree

48 files changed

+2260
-1239
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+2260
-1239
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44

55
# Upcoming
66

7+
### ✅ Added
8+
- You can now configure the policy used by SDK's AudioSession. `DefaultAudioSessionPolicy` is meant to be used for active participants in a call (1:1, group calls) and `OwnCapabilitiesAudioSessionPolicy` was designed to be used from call participants who don't actively participate in the call, but they may do in the future (e.g. Livestream viewers, Twitter Space listener etc)
9+
710
### 🐞 Fixed
811
- When a call is being created from another device than the one starting the call, if you don't provide any members, the SDK will get the information from the backend [#660](https://github.com/GetStream/stream-video-swift/pull/660)
912
- The `OutgoingCallView` provided by the default `ViewFactory` implementation won't show the current user in the ringing member bubbles [#660](https://github.com/GetStream/stream-video-swift/pull/660)

DemoApp/Sources/Components/AppEnvironment.swift

+29
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,35 @@ extension AppEnvironment {
534534
}()
535535
}
536536

537+
extension AppEnvironment {
538+
539+
enum AudioSessionPolicyDebugConfiguration: Hashable, Debuggable, Sendable {
540+
case `default`, ownCapabilities
541+
542+
var title: String {
543+
switch self {
544+
case .default:
545+
return "Default"
546+
case .ownCapabilities:
547+
return "OwnCapabilities"
548+
}
549+
}
550+
551+
var value: AudioSessionPolicy {
552+
switch self {
553+
case .default:
554+
return DefaultAudioSessionPolicy()
555+
case .ownCapabilities:
556+
return OwnCapabilitiesAudioSessionPolicy()
557+
}
558+
}
559+
}
560+
561+
static var audioSessionPolicy: AudioSessionPolicyDebugConfiguration = {
562+
.default
563+
}()
564+
}
565+
537566
extension AppEnvironment {
538567

539568
static var availableCallTypes: [String] = [

DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift

+8
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ struct SimpleCallingView: View {
182182
)
183183
}
184184

185+
private func setAudioSessionPolicyOverride(for callId: String) async throws {
186+
let call = streamVideo.call(callType: callType, callId: callId)
187+
try await call.updateAudioSessionPolicy(AppEnvironment.audioSessionPolicy.value)
188+
}
189+
185190
private func parseURLIfRequired(_ text: String) {
186191
let adapter = DeeplinkAdapter()
187192
guard
@@ -215,16 +220,19 @@ struct SimpleCallingView: View {
215220
switch action {
216221
case .lobby:
217222
await setPreferredVideoCodec(for: text)
223+
try? await setAudioSessionPolicyOverride(for: text)
218224
viewModel.enterLobby(
219225
callType: callType,
220226
callId: text,
221227
members: []
222228
)
223229
case .join:
224230
await setPreferredVideoCodec(for: text)
231+
try? await setAudioSessionPolicyOverride(for: text)
225232
viewModel.joinCall(callType: callType, callId: text)
226233
case let .start(callId):
227234
await setPreferredVideoCodec(for: callId)
235+
try? await setAudioSessionPolicyOverride(for: callId)
228236
viewModel.startCall(
229237
callType: callType,
230238
callId: callId,

DemoApp/Sources/Views/Login/DebugMenu.swift

+10
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ struct DebugMenu: View {
118118
}
119119
}
120120

121+
@State private var audioSessionPolicy = AppEnvironment.audioSessionPolicy {
122+
didSet { AppEnvironment.audioSessionPolicy = audioSessionPolicy }
123+
}
124+
121125
var body: some View {
122126
Menu {
123127
makeMenu(
@@ -177,6 +181,12 @@ struct DebugMenu: View {
177181
label: "ClosedCaptions Integration"
178182
) { self.closedCaptionsIntegration = $0 }
179183

184+
makeMenu(
185+
for: [.default, .ownCapabilities],
186+
currentValue: audioSessionPolicy,
187+
label: "AudioSession policy"
188+
) { self.audioSessionPolicy = $0 }
189+
180190
makeMenu(
181191
for: [.default, .lastParticipant],
182192
currentValue: autoLeavePolicy,

Sources/StreamVideo/Call.swift

+11
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,17 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
13351335
}
13361336
}
13371337

1338+
// MARK: - AudioSession
1339+
1340+
/// Updates the current audio session policy for the call.
1341+
///
1342+
/// - Parameter policy: A conforming `AudioSessionPolicy` that defines
1343+
/// the audio session configuration to be applied.
1344+
/// - Throws: An error if the update fails.
1345+
public func updateAudioSessionPolicy(_ policy: AudioSessionPolicy) async throws {
1346+
try await callController.updateAudioSessionPolicy(policy)
1347+
}
1348+
13381349
// MARK: - Internal
13391350

13401351
internal func update(reconnectionStatus: ReconnectionStatus) {

Sources/StreamVideo/Controllers/CallController.swift

+4
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,10 @@ class CallController: @unchecked Sendable {
468468
)
469469
}
470470

471+
func updateAudioSessionPolicy(_ policy: AudioSessionPolicy) async throws {
472+
try await webRTCCoordinator.updateAudioSessionPolicy(policy)
473+
}
474+
471475
// MARK: - private
472476

473477
private func handleParticipantsUpdated() {

Sources/StreamVideo/Models/CallSettings.swift

+1-12
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import Combine
66

77
/// Represents the settings for a call.
8-
public final class CallSettings: ObservableObject, Sendable, Equatable, CustomStringConvertible {
8+
public final class CallSettings: ObservableObject, Sendable, Equatable, ReflectiveStringConvertible {
99
/// Whether the audio is on for the current user.
1010
public let audioOn: Bool
1111
/// Whether the video is on for the current user.
@@ -46,17 +46,6 @@ public final class CallSettings: ObservableObject, Sendable, Equatable, CustomSt
4646
public var shouldPublish: Bool {
4747
audioOn || videoOn
4848
}
49-
50-
public var description: String {
51-
"""
52-
CallSettings
53-
- audioOn: \(audioOn)
54-
- videoOn: \(videoOn)
55-
- speakerOn: \(speakerOn)
56-
- audioOutputOn: \(audioOutputOn)
57-
- cameraPosition: \(cameraPosition == .front ? "front" : "back")
58-
"""
59-
}
6049
}
6150

6251
/// The camera position.

Sources/StreamVideo/Utils/AudioSession/AudioRecorder/StreamCallAudioRecorder.swift

+88-52
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,19 @@ import StreamWebRTC
1212
/// publishing the average power of the audio signal. Additionally, it adjusts its behavior based on the
1313
/// presence of an active call, automatically stopping recording if needed.
1414
open class StreamCallAudioRecorder: @unchecked Sendable {
15-
private struct StartRecordingRequest: Hashable { var hasActiveCall, ignoreActiveCall, isRecording: Bool }
15+
private let processingQueue = SerialActorQueue()
1616

1717
@Injected(\.activeCallProvider) private var activeCallProvider
1818
@Injected(\.activeCallAudioSession) private var activeCallAudioSession
1919

2020
/// The builder used to create the AVAudioRecorder instance.
2121
let audioRecorderBuilder: AVAudioRecorderBuilder
2222

23+
private let _isRecordingSubject: CurrentValueSubject<Bool, Never> = .init(false)
24+
var isRecordingPublisher: AnyPublisher<Bool, Never> {
25+
_isRecordingSubject.eraseToAnyPublisher()
26+
}
27+
2328
/// A private task responsible for setting up the recorder in the background.
2429
private var setUpTask: Task<Void, Error>?
2530

@@ -34,7 +39,12 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
3439
/// A public publisher that exposes the average power of the audio signal.
3540
open private(set) lazy var metersPublisher: AnyPublisher<Float, Never> = _metersPublisher.eraseToAnyPublisher()
3641

37-
@Atomic private var isRecording: Bool = false
42+
@Atomic private(set) var isRecording: Bool = false {
43+
willSet {
44+
activeCallAudioSession?.isRecording = newValue
45+
_isRecordingSubject.send(newValue)
46+
}
47+
}
3848

3949
/// Indicates whether an active call is present, influencing recording behaviour.
4050
private var hasActiveCall: Bool = false {
@@ -47,7 +57,7 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
4757
}
4858
}
4959

50-
private var lastStartRecordingRequest: StartRecordingRequest?
60+
private let disposableBag = DisposableBag()
5161

5262
/// Initializes the recorder with a filename.
5363
///
@@ -84,76 +94,94 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
8494
/// - ignoreActiveCall: Instructs the internal AudioRecorder to ignore the existence of an activeCall
8595
/// and start recording anyway.
8696
open func startRecording(ignoreActiveCall: Bool = false) async {
87-
do {
88-
let audioRecorder = try await setUpAudioCaptureIfRequired()
89-
let startRecordingRequest = StartRecordingRequest(
90-
hasActiveCall: hasActiveCall,
91-
ignoreActiveCall: ignoreActiveCall,
92-
isRecording: isRecording
93-
)
94-
95-
guard startRecordingRequest != lastStartRecordingRequest else {
96-
lastStartRecordingRequest = startRecordingRequest
97+
await performOperation { [weak self] in
98+
guard
99+
let self,
100+
!isRecording
101+
else {
97102
return
98103
}
99104

100-
lastStartRecordingRequest = startRecordingRequest
105+
var audioRecorder: AVAudioRecorder?
106+
do {
107+
audioRecorder = try await setUpAudioCaptureIfRequired()
108+
} catch {
109+
log.error("🎙️Failed to set up recording session", error: error)
110+
}
111+
101112
guard
102-
startRecordingRequest.hasActiveCall || startRecordingRequest.ignoreActiveCall,
103-
!startRecordingRequest.isRecording
113+
let audioRecorder,
114+
hasActiveCall || ignoreActiveCall
104115
else {
105-
log.debug(
106-
"""
107-
🎙️Attempted to start recording but failed
108-
hasActiveCall: \(startRecordingRequest.hasActiveCall)
109-
ignoreActiveCall: \(startRecordingRequest.ignoreActiveCall)
110-
isRecording: \(startRecordingRequest.isRecording)
111-
"""
112-
)
113-
return
116+
return // No-op
114117
}
118+
119+
await deferSessionActivation()
115120
audioRecorder.record()
116121
isRecording = true
117122
audioRecorder.isMeteringEnabled = true
118123

119-
log.debug("️🎙️Recording started.")
120-
updateMetersTimerCancellable = Foundation.Timer
124+
updateMetersTimerCancellable?.cancel()
125+
disposableBag.remove("update-meters")
126+
updateMetersTimerCancellable = Foundation
127+
.Timer
121128
.publish(every: 0.1, on: .main, in: .default)
122129
.autoconnect()
123-
.sink { [weak self, audioRecorder] _ in
124-
Task { [weak self, audioRecorder] in
125-
guard let self else { return }
126-
audioRecorder.updateMeters()
127-
self._metersPublisher.send(audioRecorder.averagePower(forChannel: 0))
128-
}
130+
.sinkTask(storeIn: disposableBag, identifier: "update-meters") { [weak self, audioRecorder] _ in
131+
audioRecorder.updateMeters()
132+
self?._metersPublisher.send(audioRecorder.averagePower(forChannel: 0))
129133
}
130-
} catch {
131-
isRecording = false
132-
log.error("🎙️Failed to set up recording session", error: error)
134+
135+
log.debug("️🎙️Recording started.")
133136
}
134137
}
135138

136139
/// Stops recording audio asynchronously.
137140
open func stopRecording() async {
138-
updateMetersTimerCancellable?.cancel()
139-
updateMetersTimerCancellable = nil
141+
await performOperation { [weak self] in
142+
self?.updateMetersTimerCancellable?.cancel()
143+
self?.updateMetersTimerCancellable = nil
144+
self?.disposableBag.remove("update-meters")
140145

141-
guard
142-
isRecording,
143-
let audioRecorder = await audioRecorderBuilder.result
144-
else {
145-
return
146-
}
146+
guard
147+
let self,
148+
isRecording,
149+
let audioRecorder = await audioRecorderBuilder.result
150+
else {
151+
return
152+
}
147153

148-
audioRecorder.stop()
149-
lastStartRecordingRequest = nil
150-
isRecording = false
151-
removeRecodingFile()
152-
log.debug("️🎙️Recording stopped.")
154+
audioRecorder.stop()
155+
156+
// Ensure that recorder has stopped recording.
157+
_ = try? await audioRecorder
158+
.publisher(for: \.isRecording)
159+
.filter { $0 == false }
160+
.nextValue(timeout: 0.5)
161+
162+
isRecording = false
163+
removeRecodingFile()
164+
165+
log.debug("️🎙️Recording stopped.")
166+
}
153167
}
154168

155169
// MARK: - Private helpers
156170

171+
private func performOperation(
172+
file: StaticString = #file,
173+
line: UInt = #line,
174+
_ operation: @Sendable @escaping () async -> Void
175+
) async {
176+
do {
177+
try await processingQueue.sync {
178+
await operation()
179+
}
180+
} catch {
181+
log.error(ClientError(with: error, file, line))
182+
}
183+
}
184+
157185
private func setUp() {
158186
setUpTask?.cancel()
159187
setUpTask = Task {
@@ -170,9 +198,7 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
170198
.hasActiveCallPublisher
171199
.receive(on: DispatchQueue.global(qos: .utility))
172200
.removeDuplicates()
173-
.sink { [weak self] in
174-
self?.hasActiveCall = $0
175-
}
201+
.assign(to: \.hasActiveCall, onWeak: self)
176202
}
177203

178204
private func setUpAudioCaptureIfRequired() async throws -> AVAudioRecorder {
@@ -200,6 +226,16 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
200226
log.debug("🎙️Cannot delete \(fileURL).\(error)")
201227
}
202228
}
229+
230+
private func deferSessionActivation() async {
231+
guard let activeCallAudioSession else {
232+
return
233+
}
234+
_ = try? await activeCallAudioSession
235+
.$category
236+
.filter { $0 == .playAndRecord }
237+
.nextValue(timeout: 1)
238+
}
203239
}
204240

205241
/// Provides the default value of the `StreamCallAudioRecorder` class.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import AVFoundation
6+
7+
/// Represents the audio session configuration.
8+
public struct AudioSessionConfiguration: ReflectiveStringConvertible,
9+
Equatable {
10+
/// The audio session category.
11+
var category: AVAudioSession.Category
12+
/// The audio session mode.
13+
var mode: AVAudioSession.Mode
14+
/// The audio session options.
15+
var options: AVAudioSession.CategoryOptions
16+
/// The audio session port override.
17+
var overrideOutputAudioPort: AVAudioSession.PortOverride?
18+
19+
/// Compares two `AudioSessionConfiguration` instances for equality.
20+
public static func == (lhs: Self, rhs: Self) -> Bool {
21+
lhs.category == rhs.category &&
22+
lhs.mode == rhs.mode &&
23+
lhs.options.rawValue == rhs.options.rawValue &&
24+
lhs.overrideOutputAudioPort?.rawValue ==
25+
rhs.overrideOutputAudioPort?.rawValue
26+
}
27+
}

0 commit comments

Comments
 (0)