@@ -12,14 +12,19 @@ import StreamWebRTC
12
12
/// publishing the average power of the audio signal. Additionally, it adjusts its behavior based on the
13
13
/// presence of an active call, automatically stopping recording if needed.
14
14
open class StreamCallAudioRecorder : @unchecked Sendable {
15
- private struct StartRecordingRequest : Hashable { var hasActiveCall , ignoreActiveCall , isRecording : Bool }
15
+ private let processingQueue = SerialActorQueue ( )
16
16
17
17
@Injected ( \. activeCallProvider) private var activeCallProvider
18
18
@Injected ( \. activeCallAudioSession) private var activeCallAudioSession
19
19
20
20
/// The builder used to create the AVAudioRecorder instance.
21
21
let audioRecorderBuilder : AVAudioRecorderBuilder
22
22
23
+ private let _isRecordingSubject : CurrentValueSubject < Bool , Never > = . init( false )
24
+ var isRecordingPublisher : AnyPublisher < Bool , Never > {
25
+ _isRecordingSubject. eraseToAnyPublisher ( )
26
+ }
27
+
23
28
/// A private task responsible for setting up the recorder in the background.
24
29
private var setUpTask : Task < Void , Error > ?
25
30
@@ -34,7 +39,12 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
34
39
/// A public publisher that exposes the average power of the audio signal.
35
40
open private( set) lazy var metersPublisher : AnyPublisher < Float , Never > = _metersPublisher. eraseToAnyPublisher ( )
36
41
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
+ }
38
48
39
49
/// Indicates whether an active call is present, influencing recording behaviour.
40
50
private var hasActiveCall : Bool = false {
@@ -47,7 +57,7 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
47
57
}
48
58
}
49
59
50
- private var lastStartRecordingRequest : StartRecordingRequest ?
60
+ private let disposableBag = DisposableBag ( )
51
61
52
62
/// Initializes the recorder with a filename.
53
63
///
@@ -84,76 +94,94 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
84
94
/// - ignoreActiveCall: Instructs the internal AudioRecorder to ignore the existence of an activeCall
85
95
/// and start recording anyway.
86
96
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 {
97
102
return
98
103
}
99
104
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
+
101
112
guard
102
- startRecordingRequest . hasActiveCall || startRecordingRequest . ignoreActiveCall ,
103
- !startRecordingRequest . isRecording
113
+ let audioRecorder ,
114
+ hasActiveCall || ignoreActiveCall
104
115
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
114
117
}
118
+
119
+ await deferSessionActivation ( )
115
120
audioRecorder. record ( )
116
121
isRecording = true
117
122
audioRecorder. isMeteringEnabled = true
118
123
119
- log. debug ( " ️🎙️Recording started. " )
120
- updateMetersTimerCancellable = Foundation . Timer
124
+ updateMetersTimerCancellable? . cancel ( )
125
+ disposableBag. remove ( " update-meters " )
126
+ updateMetersTimerCancellable = Foundation
127
+ . Timer
121
128
. publish ( every: 0.1 , on: . main, in: . default)
122
129
. 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 ) )
129
133
}
130
- } catch {
131
- isRecording = false
132
- log. error ( " 🎙️Failed to set up recording session " , error: error)
134
+
135
+ log. debug ( " ️🎙️Recording started. " )
133
136
}
134
137
}
135
138
136
139
/// Stops recording audio asynchronously.
137
140
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 " )
140
145
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
+ }
147
153
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
+ }
153
167
}
154
168
155
169
// MARK: - Private helpers
156
170
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
+
157
185
private func setUp( ) {
158
186
setUpTask? . cancel ( )
159
187
setUpTask = Task {
@@ -170,9 +198,7 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
170
198
. hasActiveCallPublisher
171
199
. receive ( on: DispatchQueue . global ( qos: . utility) )
172
200
. removeDuplicates ( )
173
- . sink { [ weak self] in
174
- self ? . hasActiveCall = $0
175
- }
201
+ . assign ( to: \. hasActiveCall, onWeak: self )
176
202
}
177
203
178
204
private func setUpAudioCaptureIfRequired( ) async throws -> AVAudioRecorder {
@@ -200,6 +226,16 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
200
226
log. debug ( " 🎙️Cannot delete \( fileURL) . \( error) " )
201
227
}
202
228
}
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
+ }
203
239
}
204
240
205
241
/// Provides the default value of the `StreamCallAudioRecorder` class.
0 commit comments