Skip to content

Commit

Permalink
Update AudioSession stack
Browse files Browse the repository at this point in the history
  • Loading branch information
ipavlidakis committed Feb 6, 2025
1 parent fb7ad4a commit 194ea1a
Show file tree
Hide file tree
Showing 12 changed files with 772 additions and 572 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import StreamWebRTC
/// publishing the average power of the audio signal. Additionally, it adjusts its behavior based on the
/// presence of an active call, automatically stopping recording if needed.
open class StreamCallAudioRecorder: @unchecked Sendable {
private struct StartRecordingRequest: Hashable { var hasActiveCall, ignoreActiveCall, isRecording: Bool }
private let processingQueue = SerialActorQueue()

@Injected(\.activeCallProvider) private var activeCallProvider
@Injected(\.activeCallAudioSession) private var activeCallAudioSession
Expand Down Expand Up @@ -40,7 +40,10 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
open private(set) lazy var metersPublisher: AnyPublisher<Float, Never> = _metersPublisher.eraseToAnyPublisher()

@Atomic private(set) var isRecording: Bool = false {
willSet { _isRecordingSubject.send(newValue) }
willSet {
activeCallAudioSession?.isRecording = newValue
_isRecordingSubject.send(newValue)
}
}

/// Indicates whether an active call is present, influencing recording behaviour.
Expand All @@ -54,7 +57,7 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
}
}

private var lastStartRecordingRequest: StartRecordingRequest?
private let disposableBag = DisposableBag()

/// Initializes the recorder with a filename.
///
Expand Down Expand Up @@ -91,92 +94,94 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
/// - ignoreActiveCall: Instructs the internal AudioRecorder to ignore the existence of an activeCall
/// and start recording anyway.
open func startRecording(ignoreActiveCall: Bool = false) async {
do {
let audioRecorder = try await setUpAudioCaptureIfRequired()
let startRecordingRequest = StartRecordingRequest(
hasActiveCall: hasActiveCall,
ignoreActiveCall: ignoreActiveCall,
isRecording: isRecording
)

guard startRecordingRequest != lastStartRecordingRequest else {
lastStartRecordingRequest = startRecordingRequest
await performOperation { [weak self] in
guard
let self,
!isRecording
else {
return
}

lastStartRecordingRequest = startRecordingRequest
var audioRecorder: AVAudioRecorder?
do {
audioRecorder = try await setUpAudioCaptureIfRequired()
} catch {
log.error("🎙️Failed to set up recording session", error: error)
}

guard
startRecordingRequest.hasActiveCall || startRecordingRequest.ignoreActiveCall,
!startRecordingRequest.isRecording
let audioRecorder,
hasActiveCall || ignoreActiveCall
else {
log.debug(
"""
🎙️Attempted to start recording but failed
hasActiveCall: \(startRecordingRequest.hasActiveCall)
ignoreActiveCall: \(startRecordingRequest.ignoreActiveCall)
isRecording: \(startRecordingRequest.isRecording)
"""
)
return
return // No-op
}

await deferSessionActivation()
audioRecorder.record()
isRecording = true
audioRecorder.isMeteringEnabled = true

log.debug("️🎙️Recording started.")
updateMetersTimerCancellable = Foundation.Timer
updateMetersTimerCancellable?.cancel()
disposableBag.remove("update-meters")
updateMetersTimerCancellable = Foundation
.Timer
.publish(every: 0.1, on: .main, in: .default)
.autoconnect()
.sink { [weak self, audioRecorder] _ in
Task { [weak self, audioRecorder] in
guard let self else { return }
audioRecorder.updateMeters()
self._metersPublisher.send(audioRecorder.averagePower(forChannel: 0))
}
.sinkTask(storeIn: disposableBag, identifier: "update-meters") { [weak self, audioRecorder] _ in
audioRecorder.updateMeters()
self?._metersPublisher.send(audioRecorder.averagePower(forChannel: 0))
}
} catch {
isRecording = false
log.error("🎙️Failed to set up recording session", error: error)
}
}

private func deferSessionActivation() async {
guard let activeCallAudioSession else {
return
log.debug("️🎙️Recording started.")
}
_ = try? await activeCallAudioSession
.canRecordPublisher
.filter { $0 == true }
.nextValue(timeout: 1)
}

/// Stops recording audio asynchronously.
open func stopRecording() async {
updateMetersTimerCancellable?.cancel()
updateMetersTimerCancellable = nil
await performOperation { [weak self] in
self?.updateMetersTimerCancellable?.cancel()
self?.updateMetersTimerCancellable = nil
self?.disposableBag.remove("update-meters")

guard
isRecording,
let audioRecorder = await audioRecorderBuilder.result
else {
return
}
guard
let self,
isRecording,
let audioRecorder = await audioRecorderBuilder.result
else {
return
}

audioRecorder.stop()
lastStartRecordingRequest = nil
_ = try? await audioRecorder
.publisher(for: \.isRecording)
.filter { $0 == false }
.nextValue(timeout: 1)
isRecording = false
removeRecodingFile()
log.debug("️🎙️Recording stopped.")
audioRecorder.stop()

// Ensure that recorder has stopped recording.
_ = try? await audioRecorder
.publisher(for: \.isRecording)
.filter { $0 == false }
.nextValue(timeout: 0.5)

isRecording = false
removeRecodingFile()

log.debug("️🎙️Recording stopped.")
}
}

// MARK: - Private helpers

private func performOperation(
file: StaticString = #file,
line: UInt = #line,
_ operation: @Sendable @escaping () async -> Void
) async {
do {
try await processingQueue.sync {
await operation()
}
} catch {
log.error(ClientError(with: error, file, line))
}
}

private func setUp() {
setUpTask?.cancel()
setUpTask = Task {
Expand All @@ -193,9 +198,7 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
.hasActiveCallPublisher
.receive(on: DispatchQueue.global(qos: .utility))
.removeDuplicates()
.sink { [weak self] in
self?.hasActiveCall = $0
}
.assign(to: \.hasActiveCall, onWeak: self)
}

private func setUpAudioCaptureIfRequired() async throws -> AVAudioRecorder {
Expand Down Expand Up @@ -223,6 +226,16 @@ open class StreamCallAudioRecorder: @unchecked Sendable {
log.debug("🎙️Cannot delete \(fileURL).\(error)")
}
}

private func deferSessionActivation() async {
guard let activeCallAudioSession else {
return
}
_ = try? await activeCallAudioSession
.categoryPublisher
.filter { $0 == .playAndRecord }
.nextValue(timeout: 1)
}
}

/// Provides the default value of the `StreamCallAudioRecorder` class.
Expand Down
97 changes: 0 additions & 97 deletions Sources/StreamVideo/Utils/AudioSession/AudioSessionProtocol.swift

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import AVFoundation
import Foundation

extension AVAudioSession {
/// Asynchronously requests permission to record audio.
/// - Returns: A Boolean indicating whether permission was granted.
private func requestRecordPermission() async -> Bool {
await withCheckedContinuation { continuation in
self.requestRecordPermission { result in
continuation.resume(returning: result)
}
}
}
}
Loading

0 comments on commit 194ea1a

Please sign in to comment.