Skip to content

Commit

Permalink
[Enhancement]Improve AudioSession management (#639)
Browse files Browse the repository at this point in the history
  • Loading branch information
ipavlidakis authored Feb 13, 2025
1 parent 0fd1452 commit 0e7b406
Show file tree
Hide file tree
Showing 48 changed files with 2,260 additions and 1,239 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### ✅ Added
- 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)

### 🐞 Fixed
- 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)
- 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)
Expand Down
29 changes: 29 additions & 0 deletions DemoApp/Sources/Components/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,35 @@ extension AppEnvironment {
}()
}

extension AppEnvironment {

enum AudioSessionPolicyDebugConfiguration: Hashable, Debuggable, Sendable {
case `default`, ownCapabilities

var title: String {
switch self {
case .default:
return "Default"
case .ownCapabilities:
return "OwnCapabilities"
}
}

var value: AudioSessionPolicy {
switch self {
case .default:
return DefaultAudioSessionPolicy()
case .ownCapabilities:
return OwnCapabilitiesAudioSessionPolicy()
}
}
}

static var audioSessionPolicy: AudioSessionPolicyDebugConfiguration = {
.default
}()
}

extension AppEnvironment {

static var availableCallTypes: [String] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ struct SimpleCallingView: View {
)
}

private func setAudioSessionPolicyOverride(for callId: String) async throws {
let call = streamVideo.call(callType: callType, callId: callId)
try await call.updateAudioSessionPolicy(AppEnvironment.audioSessionPolicy.value)
}

private func parseURLIfRequired(_ text: String) {
let adapter = DeeplinkAdapter()
guard
Expand Down Expand Up @@ -215,16 +220,19 @@ struct SimpleCallingView: View {
switch action {
case .lobby:
await setPreferredVideoCodec(for: text)
try? await setAudioSessionPolicyOverride(for: text)
viewModel.enterLobby(
callType: callType,
callId: text,
members: []
)
case .join:
await setPreferredVideoCodec(for: text)
try? await setAudioSessionPolicyOverride(for: text)
viewModel.joinCall(callType: callType, callId: text)
case let .start(callId):
await setPreferredVideoCodec(for: callId)
try? await setAudioSessionPolicyOverride(for: callId)
viewModel.startCall(
callType: callType,
callId: callId,
Expand Down
10 changes: 10 additions & 0 deletions DemoApp/Sources/Views/Login/DebugMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ struct DebugMenu: View {
}
}

@State private var audioSessionPolicy = AppEnvironment.audioSessionPolicy {
didSet { AppEnvironment.audioSessionPolicy = audioSessionPolicy }
}

var body: some View {
Menu {
makeMenu(
Expand Down Expand Up @@ -177,6 +181,12 @@ struct DebugMenu: View {
label: "ClosedCaptions Integration"
) { self.closedCaptionsIntegration = $0 }

makeMenu(
for: [.default, .ownCapabilities],
currentValue: audioSessionPolicy,
label: "AudioSession policy"
) { self.audioSessionPolicy = $0 }

makeMenu(
for: [.default, .lastParticipant],
currentValue: autoLeavePolicy,
Expand Down
11 changes: 11 additions & 0 deletions Sources/StreamVideo/Call.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1335,6 +1335,17 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
}
}

// MARK: - AudioSession

/// Updates the current audio session policy for the call.
///
/// - 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)
}

// MARK: - Internal

internal func update(reconnectionStatus: ReconnectionStatus) {
Expand Down
4 changes: 4 additions & 0 deletions Sources/StreamVideo/Controllers/CallController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,10 @@ class CallController: @unchecked Sendable {
)
}

func updateAudioSessionPolicy(_ policy: AudioSessionPolicy) async throws {
try await webRTCCoordinator.updateAudioSessionPolicy(policy)
}

// MARK: - private

private func handleParticipantsUpdated() {
Expand Down
13 changes: 1 addition & 12 deletions Sources/StreamVideo/Models/CallSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import Combine

/// Represents the settings for a call.
public final class CallSettings: ObservableObject, Sendable, Equatable, CustomStringConvertible {
public final class CallSettings: ObservableObject, Sendable, Equatable, ReflectiveStringConvertible {
/// Whether the audio is on for the current user.
public let audioOn: Bool
/// Whether the video is on for the current user.
Expand Down Expand Up @@ -46,17 +46,6 @@ public final class CallSettings: ObservableObject, Sendable, Equatable, CustomSt
public var shouldPublish: Bool {
audioOn || videoOn
}

public var description: String {
"""
CallSettings
- audioOn: \(audioOn)
- videoOn: \(videoOn)
- speakerOn: \(speakerOn)
- audioOutputOn: \(audioOutputOn)
- cameraPosition: \(cameraPosition == .front ? "front" : "back")
"""
}
}

/// The camera position.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,19 @@ 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

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

private let _isRecordingSubject: CurrentValueSubject<Bool, Never> = .init(false)
var isRecordingPublisher: AnyPublisher<Bool, Never> {
_isRecordingSubject.eraseToAnyPublisher()
}

/// A private task responsible for setting up the recorder in the background.
private var setUpTask: Task<Void, Error>?

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

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

/// Indicates whether an active call is present, influencing recording behaviour.
private var hasActiveCall: Bool = false {
Expand All @@ -47,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 @@ -84,76 +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)

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

/// 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
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 @@ -170,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 @@ -200,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
.$category
.filter { $0 == .playAndRecord }
.nextValue(timeout: 1)
}
}

/// Provides the default value of the `StreamCallAudioRecorder` class.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import AVFoundation

/// Represents the audio session configuration.
public struct AudioSessionConfiguration: ReflectiveStringConvertible,
Equatable {
/// The audio session category.
var category: AVAudioSession.Category
/// The audio session mode.
var mode: AVAudioSession.Mode
/// The audio session options.
var options: AVAudioSession.CategoryOptions
/// The audio session port override.
var overrideOutputAudioPort: AVAudioSession.PortOverride?

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

0 comments on commit 0e7b406

Please sign in to comment.