diff --git a/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/StreamCallAudioRecorder.swift b/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/StreamCallAudioRecorder.swift index 9fb44a5ee..d27d6be9f 100644 --- a/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/StreamCallAudioRecorder.swift +++ b/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/StreamCallAudioRecorder.swift @@ -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 @@ -40,7 +40,10 @@ open class StreamCallAudioRecorder: @unchecked Sendable { open private(set) lazy var metersPublisher: AnyPublisher = _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. @@ -54,7 +57,7 @@ open class StreamCallAudioRecorder: @unchecked Sendable { } } - private var lastStartRecordingRequest: StartRecordingRequest? + private let disposableBag = DisposableBag() /// Initializes the recorder with a filename. /// @@ -91,33 +94,26 @@ 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() @@ -125,58 +121,67 @@ open class StreamCallAudioRecorder: @unchecked Sendable { 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 { @@ -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 { @@ -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. diff --git a/Sources/StreamVideo/Utils/AudioSession/AudioSessionProtocol.swift b/Sources/StreamVideo/Utils/AudioSession/AudioSessionProtocol.swift deleted file mode 100644 index 073d37cb1..000000000 --- a/Sources/StreamVideo/Utils/AudioSession/AudioSessionProtocol.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import AVFoundation -import Foundation -import StreamWebRTC - -/// A protocol defining the interface for managing an audio session, -/// with properties and methods to control audio settings, activation, -/// and routing configurations. -public protocol AudioSessionProtocol: AnyObject { - - /// A Boolean value indicating whether the audio session is active. - var isActive: Bool { get } - - /// The current route description for the audio session. - var currentRoute: AVAudioSessionRouteDescription { get } - - /// The audio category of the session. - var category: String { get } - - /// A Boolean value indicating whether the audio session uses speaker output. - var isUsingSpeakerOutput: Bool { get } - - /// A Boolean value indicating whether the audio session uses an external - /// audio output, such as headphones or Bluetooth. - var isUsingExternalOutput: Bool { get } - - /// A Boolean value indicating whether the session uses manual audio routing. - var useManualAudio: Bool { get set } - - /// A Boolean value indicating whether audio is enabled for the session. - var isAudioEnabled: Bool { get set } - - var hasEarpiece: Bool { get } - - /// Adds a delegate to receive updates about audio session events. - /// - Parameter delegate: The delegate conforming to `RTCAudioSessionDelegate`. - func add(_ delegate: RTCAudioSessionDelegate) - - /// Configures the audio category and options for the session. - /// - Parameters: - /// - category: The audio category to set, like `.playAndRecord`. - /// - categoryOptions: Options for the audio category, such as - /// `.allowBluetooth` or `.defaultToSpeaker`. - /// - Throws: An error if setting the mode fails, usually because the configuration hasn't been locked. - /// Prefer wrapping this method using `updateConfiguration`. - func setCategory( - _ category: AVAudioSession.Category, - mode: AVAudioSession.Mode, - with categoryOptions: AVAudioSession.CategoryOptions - ) throws - - /// Sets the session configuration for WebRTC audio settings. - /// - Parameter configuration: The configuration to apply to the session. - /// - Throws: An error if setting the mode fails, usually because the configuration hasn't been locked. - /// Prefer wrapping this method using `updateConfiguration`. - func setConfiguration(_ configuration: RTCAudioSessionConfiguration) throws - - /// Overrides the current output audio port for the session. - /// - Parameter port: The port to use, such as `.speaker` or `.none`. - /// - Throws: An error if setting the mode fails, usually because the configuration hasn't been locked. - /// Prefer wrapping this method using `updateConfiguration`. - func overrideOutputAudioPort(_ port: AVAudioSession.PortOverride) throws - - /// Updates the audio session configuration by performing an asynchronous - /// operation. - /// - Parameters: - /// - functionName: The name of the calling function. - /// - file: The source file of the calling function. - /// - line: The line number of the calling function. - /// - block: The closure to execute, providing the audio session for - /// configuration updates. - func updateConfiguration( - functionName: StaticString, - file: StaticString, - line: UInt, - _ block: @escaping (AudioSessionProtocol) throws -> Void - ) async - - /// Requests permission to record audio from the user. - /// - Returns: A Boolean indicating whether permission was granted. - func requestRecordPermission() async -> Bool -} - -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) - } - } - } -} diff --git a/Sources/StreamVideo/Utils/AudioSession/Extensions/AVAudioSession+RequestRecordPermission.swift b/Sources/StreamVideo/Utils/AudioSession/Extensions/AVAudioSession+RequestRecordPermission.swift new file mode 100644 index 000000000..7214bb3b1 --- /dev/null +++ b/Sources/StreamVideo/Utils/AudioSession/Extensions/AVAudioSession+RequestRecordPermission.swift @@ -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) + } + } + } +} diff --git a/Sources/StreamVideo/Utils/AudioSession/RTCAudioSessionDelegatePublisher.swift b/Sources/StreamVideo/Utils/AudioSession/RTCAudioSessionDelegatePublisher.swift new file mode 100644 index 000000000..4449470be --- /dev/null +++ b/Sources/StreamVideo/Utils/AudioSession/RTCAudioSessionDelegatePublisher.swift @@ -0,0 +1,207 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import AVFoundation +import Combine +import StreamWebRTC + +/// Enumeration representing all the events published by the delegate. +enum AudioSessionEvent { + case didBeginInterruption(session: RTCAudioSession) + + case didEndInterruption(session: RTCAudioSession, shouldResumeSession: Bool) + + case didChangeRoute( + session: RTCAudioSession, + reason: AVAudioSession.RouteChangeReason, + previousRoute: AVAudioSessionRouteDescription + ) + + case mediaServerTerminated(session: RTCAudioSession) + + case mediaServerReset(session: RTCAudioSession) + + case didChangeCanPlayOrRecord( + session: RTCAudioSession, + canPlayOrRecord: Bool + ) + + case didStartPlayOrRecord(session: RTCAudioSession) + + case didStopPlayOrRecord(session: RTCAudioSession) + + case didChangeOutputVolume( + audioSession: RTCAudioSession, + outputVolume: Float + ) + + case didDetectPlayoutGlitch( + audioSession: RTCAudioSession, + totalNumberOfGlitches: Int64 + ) + + case willSetActive(audioSession: RTCAudioSession, active: Bool) + + case didSetActive(audioSession: RTCAudioSession, active: Bool) + + case failedToSetActive( + audioSession: RTCAudioSession, + active: Bool, + error: Error + ) + + case audioUnitStartFailedWithError( + audioSession: RTCAudioSession, + error: Error + ) +} + +// MARK: - Delegate Publisher Class + +/// A delegate that publishes all RTCAudioSessionDelegate events via a Combine PassthroughSubject. +@objc +final class RTCAudioSessionDelegatePublisher: NSObject, RTCAudioSessionDelegate { + + /// The subject used to publish delegate events. + private let subject = PassthroughSubject() + + /// A public publisher that subscribers can listen to. + var publisher: AnyPublisher { + subject.eraseToAnyPublisher() + } + + // MARK: - RTCAudioSessionDelegate Methods + + func audioSessionDidBeginInterruption(_ session: RTCAudioSession) { + subject.send(.didBeginInterruption(session: session)) + } + + func audioSessionDidEndInterruption( + _ session: RTCAudioSession, + shouldResumeSession: Bool + ) { + subject.send( + .didEndInterruption( + session: session, + shouldResumeSession: shouldResumeSession + ) + ) + } + + func audioSessionDidChangeRoute( + _ session: RTCAudioSession, + reason: AVAudioSession.RouteChangeReason, + previousRoute: AVAudioSessionRouteDescription + ) { + subject.send( + .didChangeRoute( + session: session, + reason: reason, + previousRoute: previousRoute + ) + ) + } + + func audioSessionMediaServerTerminated(_ session: RTCAudioSession) { + subject.send(.mediaServerTerminated(session: session)) + } + + func audioSessionMediaServerReset(_ session: RTCAudioSession) { + subject.send(.mediaServerReset(session: session)) + } + + func audioSession( + _ session: RTCAudioSession, + didChangeCanPlayOrRecord canPlayOrRecord: Bool + ) { + subject.send( + .didChangeCanPlayOrRecord( + session: session, + canPlayOrRecord: canPlayOrRecord + ) + ) + } + + func audioSessionDidStartPlayOrRecord(_ session: RTCAudioSession) { + subject.send(.didStartPlayOrRecord(session: session)) + } + + func audioSessionDidStopPlayOrRecord(_ session: RTCAudioSession) { + subject.send(.didStopPlayOrRecord(session: session)) + } + + func audioSession( + _ audioSession: RTCAudioSession, + didChangeOutputVolume outputVolume: Float + ) { + subject.send( + .didChangeOutputVolume( + audioSession: audioSession, + outputVolume: outputVolume + ) + ) + } + + func audioSession( + _ audioSession: RTCAudioSession, + didDetectPlayoutGlitch totalNumberOfGlitches: Int64 + ) { + subject.send( + .didDetectPlayoutGlitch( + audioSession: audioSession, + totalNumberOfGlitches: totalNumberOfGlitches + ) + ) + } + + func audioSession( + _ audioSession: RTCAudioSession, + willSetActive active: Bool + ) { + subject.send( + .willSetActive( + audioSession: audioSession, + active: active + ) + ) + } + + func audioSession( + _ audioSession: RTCAudioSession, + didSetActive active: Bool + ) { + subject.send( + .didSetActive( + audioSession: audioSession, + active: active + ) + ) + } + + func audioSession( + _ audioSession: RTCAudioSession, + failedToSetActive active: Bool, + error: Error + ) { + subject.send( + .failedToSetActive( + audioSession: audioSession, + active: active, + error: error + ) + ) + } + + func audioSession( + _ audioSession: RTCAudioSession, + audioUnitStartFailedWithError error: Error + ) { + subject.send( + .audioUnitStartFailedWithError( + audioSession: audioSession, + error: error + ) + ) + } +} diff --git a/Sources/StreamVideo/Utils/AudioSession/StreamAudioSession.swift b/Sources/StreamVideo/Utils/AudioSession/StreamAudioSession.swift new file mode 100644 index 000000000..add5520cb --- /dev/null +++ b/Sources/StreamVideo/Utils/AudioSession/StreamAudioSession.swift @@ -0,0 +1,297 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import AVFoundation +import Combine +import Foundation +import StreamWebRTC + +/// The `StreamAudioSessionAdapter` class manages the device's audio session +/// for an app, enabling control over activation, configuration, and routing +/// to output devices like speakers and in-ear speakers. +final class StreamAudioSession: @unchecked Sendable, ObservableObject { + + private var currentDevice = CurrentDevice.currentValue + + private var audioRouteChangeCancellable: AnyCancellable? + /// The shared audio session instance conforming to `AudioSessionProtocol` + /// that manages WebRTC audio settings. + private let audioSession: StreamRTCAudioSession = .init() + private var hasBeenConfigured = false + + /// The current active call settings, or `nil` if no active call is in session. + @Atomic private var activeCallSettings: CallSettings + + var categoryPublisher: AnyPublisher { + audioSession.$state.map(\.category).eraseToAnyPublisher() + } + + /// The delegate for receiving audio session events, such as call settings + /// updates. + weak var delegate: StreamAudioSessionAdapterDelegate? + + // MARK: - AudioSession State + + @Published var isRecording: Bool = false + + var isActive: Bool { audioSession.isActive } + + var currentRoute: AVAudioSessionRouteDescription { audioSession.currentRoute } + + /// Initializes a new `StreamAudioSessionAdapter` instance, configuring + /// the session with default settings and enabling manual audio control + /// for WebRTC.w + /// - Parameter audioSession: An `AudioSessionProtocol` instance. Defaults + /// to `StreamRTCAudioSession`. + required init( + callSettings: CallSettings + ) { + activeCallSettings = callSettings + + /// Update the active call's `audioSession` to make available to other components. + Self.currentValue = self + + audioSession.useManualAudio = true + audioSession.isAudioEnabled = true + + audioRouteChangeCancellable = audioSession + .eventPublisher + .compactMap { + switch $0 { + case let .didChangeRoute(session, reason, previousRoute): + return (session, reason, previousRoute) + default: + return nil + } + } + .log(.debug, subsystems: .audioSession) { session, reason, previousRoute in + """ + AudioSession didChangeRoute reason:\(reason) + - category: \(AVAudioSession.Category(rawValue: session.category)) + - mode: \(AVAudioSession.Mode(rawValue: session.mode)) + - categoryOptions: \(session.categoryOptions) + - currentRoute:\(session.currentRoute) + - previousRoute:\(previousRoute) + """ + } + .sink { [weak self] in + self?.audioSessionDidChangeRoute( + $0, + reason: $1, + previousRoute: $2 + ) + } + } + + nonisolated func dismantle() { + audioRouteChangeCancellable?.cancel() + audioRouteChangeCancellable = nil + if Self.currentValue === self { + // Reset activeCall audioSession. + Self.currentValue = nil + } + } + + // MARK: - CallSettings + + /// Updates the audio session with new call settings. + /// - Parameter settings: The new `CallSettings` to apply. + func didUpdateCallSettings( + _ settings: CallSettings + ) async throws { + activeCallSettings = settings + try await didUpdate(settings) + } + + func prepareForRecording() async throws { + guard !activeCallSettings.audioOn else { + return + } + + activeCallSettings = activeCallSettings.withUpdatedAudioState(true) + try await didUpdate(activeCallSettings) + } + + func requestRecordPermission() async -> Bool { + await audioSession.requestRecordPermission() + } + + // MARK: - Private helpers + + /// Handles audio route changes, updating the session based on the reason + /// for the change. + /// + /// For cases like `.newDeviceAvailable`, `.override`, + /// `.noSuitableRouteForCategory`, `.routeConfigurationChange`, `.default`, + /// or `.unknown`, the route change is accepted, and the `CallSettings` + /// are updated accordingly, triggering a delegate update. + /// + /// For other cases, the route change is ignored, enforcing the existing + /// `CallSettings`. + /// + /// - Parameters: + /// - session: The `RTCAudioSession` instance. + /// - reason: The reason for the route change. + /// - previousRoute: The previous audio route configuration. + private func audioSessionDidChangeRoute( + _ session: RTCAudioSession, + reason: AVAudioSession.RouteChangeReason, + previousRoute: AVAudioSessionRouteDescription + ) { + guard currentDevice.deviceType == .phone else { + if activeCallSettings.speakerOn != session.currentRoute.isSpeaker { + delegate?.audioSessionAdapterDidUpdateCallSettings( + self, + callSettings: activeCallSettings + .withUpdatedSpeakerState(session.currentRoute.isSpeaker) + ) + } + return + } + + switch (activeCallSettings.speakerOn, session.currentRoute.isSpeaker) { + case (true, false): + delegate?.audioSessionAdapterDidUpdateCallSettings( + self, + callSettings: activeCallSettings.withUpdatedSpeakerState(false) + ) + + case (false, true) where session.category == AVAudioSession.Category.playAndRecord.rawValue: + delegate?.audioSessionAdapterDidUpdateCallSettings( + self, + callSettings: activeCallSettings.withUpdatedSpeakerState(true) + ) + + default: + break + } + } + + private func configureAudioSession(settings: CallSettings) async throws { + let configuration = settings.audioSessionConfiguration + + try await audioSession.setCategory( + .init(rawValue: configuration.category), + mode: .init(rawValue: configuration.mode), + with: configuration.categoryOptions + ) + + hasBeenConfigured = true + log.debug( + "AudioSession was configured with \(configuration)", + subsystems: .audioSession + ) + } + + private func didUpdate( + _ callSettings: CallSettings, + file: StaticString = #file, + functionName: StaticString = #function, + line: UInt = #line + ) async throws { + guard hasBeenConfigured else { + try await configureAudioSession(settings: callSettings) + return + } + + if callSettings.audioOn == false, isRecording { + log.debug( + "Will defer execution until recording has stopped.", + subsystems: .audioSession, + functionName: functionName, + fileName: file, + lineNumber: line + ) + await deferExecutionUntilRecordingIsStopped() + } + + let category: AVAudioSession.Category = callSettings.audioOn + || callSettings.speakerOn + || callSettings.videoOn + ? .playAndRecord + : .playback + + let mode: AVAudioSession.Mode = category == .playAndRecord + ? callSettings.speakerOn == true ? .videoChat : .voiceChat + : .default + + let categoryOptions: AVAudioSession.CategoryOptions = category == .playAndRecord + ? .playAndRecord + : .playback + + let overridePort: AVAudioSession.PortOverride? = category == .playAndRecord + ? callSettings.speakerOn == true ? .speaker : AVAudioSession.PortOverride.none + : nil + + if + overridePort == nil, + audioSession.state.category == AVAudioSession.Category.playAndRecord + { + try await audioSession.overrideOutputAudioPort(.none) + } + + do { + try await audioSession.setCategory( + category, + mode: mode, + with: categoryOptions + ) + } catch { + log.error( + "Failed while setting category:\(category) mode:\(mode) options:\(categoryOptions)", + subsystems: .audioSession, + error: error, + functionName: functionName, + fileName: file, + lineNumber: line + ) + } + + if let overridePort { + try await audioSession.overrideOutputAudioPort(overridePort) + } + + log.debug( + "AudioSession updated with state \(audioSession.state)", + subsystems: .audioSession, + functionName: functionName, + fileName: file, + lineNumber: line + ) + } + + private func deferExecutionUntilRecordingIsStopped() async { + do { + _ = try await $isRecording + .filter { $0 == false } + .nextValue(timeout: 1) + try await Task.sleep(nanoseconds: 250 * 1_000_000) + } catch { + log.error( + "Defer execution until recording has stopped failed.", + subsystems: .audioSession, + error: error + ) + } + } +} + +/// A key for dependency injection of an `AudioSessionProtocol` instance +/// that represents the active call audio session. +extension StreamAudioSession: InjectionKey { + static var currentValue: StreamAudioSession? +} + +extension InjectedValues { + /// The active call's audio session. The value is being set on `StreamAudioSessionAdapter` + /// `init` / `deinit` + var activeCallAudioSession: StreamAudioSession? { + get { + Self[StreamAudioSession.self] + } + set { + Self[StreamAudioSession.self] = newValue + } + } +} diff --git a/Sources/StreamVideo/Utils/AudioSession/StreamAudioSessionAdapter.swift b/Sources/StreamVideo/Utils/AudioSession/StreamAudioSessionAdapter.swift deleted file mode 100644 index a9a5a2d24..000000000 --- a/Sources/StreamVideo/Utils/AudioSession/StreamAudioSessionAdapter.swift +++ /dev/null @@ -1,273 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import AVFoundation -import Combine -import Foundation -import StreamWebRTC - -/// The `StreamAudioSessionAdapter` class manages the device's audio session -/// for an app, enabling control over activation, configuration, and routing -/// to output devices like speakers and in-ear speakers. -final class StreamAudioSessionAdapter: NSObject, RTCAudioSessionDelegate, @unchecked Sendable { - - @Injected(\.callAudioRecorder) private var callAudioRecorder - - /// The shared audio session instance conforming to `AudioSessionProtocol` - /// that manages WebRTC audio settings. - private let audioSession: AudioSessionProtocol - private let serialQueue = SerialActorQueue() - private var hasBeenConfigured = false - - /// The current active call settings, or `nil` if no active call is in session. - @Atomic private(set) var activeCallSettings: CallSettings - - private let canRecordSubject = PassthroughSubject() - var canRecordPublisher: AnyPublisher { canRecordSubject.eraseToAnyPublisher() } - - /// The delegate for receiving audio session events, such as call settings - /// updates. - weak var delegate: StreamAudioSessionAdapterDelegate? - - /// Initializes a new `StreamAudioSessionAdapter` instance, configuring - /// the session with default settings and enabling manual audio control - /// for WebRTC.w - /// - Parameter audioSession: An `AudioSessionProtocol` instance. Defaults - /// to `StreamRTCAudioSession`. - required init( - _ audioSession: AudioSessionProtocol = StreamRTCAudioSession(), - callSettings: CallSettings - ) { - self.audioSession = audioSession - activeCallSettings = callSettings - super.init() - - /// Update the active call's `audioSession` to make available to other components. - StreamActiveCallAudioSessionKey.currentValue = self - - audioSession.add(self) - audioSession.useManualAudio = true - audioSession.isAudioEnabled = true - } - - nonisolated func dismantle() { - if StreamActiveCallAudioSessionKey.currentValue === self { - // Reset activeCall audioSession. - StreamActiveCallAudioSessionKey.currentValue = nil - } - } - - // MARK: - CallSettings - - /// Updates the audio session with new call settings. - /// - Parameter settings: The new `CallSettings` to apply. - func didUpdateCallSettings( - _ settings: CallSettings - ) { - let oldValue = activeCallSettings - activeCallSettings = settings - didUpdate(settings, oldValue: oldValue) - } - - func prepareForRecording() { - guard !activeCallSettings.audioOn else { - return - } - - let settings = activeCallSettings - .withUpdatedAudioState(true) - let oldValue = activeCallSettings - activeCallSettings = settings - didUpdate(settings, oldValue: oldValue) - } - - func requestRecordPermission() async -> Bool { - await audioSession.requestRecordPermission() - } - - // MARK: - RTCAudioSessionDelegate - - /// Handles audio route changes, updating the session based on the reason - /// for the change. - /// - /// For cases like `.newDeviceAvailable`, `.override`, - /// `.noSuitableRouteForCategory`, `.routeConfigurationChange`, `.default`, - /// or `.unknown`, the route change is accepted, and the `CallSettings` - /// are updated accordingly, triggering a delegate update. - /// - /// For other cases, the route change is ignored, enforcing the existing - /// `CallSettings`. - /// - /// - Parameters: - /// - session: The `RTCAudioSession` instance. - /// - reason: The reason for the route change. - /// - previousRoute: The previous audio route configuration. - func audioSessionDidChangeRoute( - _ session: RTCAudioSession, - reason: AVAudioSession.RouteChangeReason, - previousRoute: AVAudioSessionRouteDescription - ) { - log.debug( - """ - AudioSession didChangeRoute reason:\(reason) (session hasEarpiece:\(session.hasEarpiece)) - - currentRoute:\(session.currentRoute) - - previousRoute:\(previousRoute) - """, - subsystems: .audioSession - ) - - guard session.hasEarpiece else { - if activeCallSettings.speakerOn != session.currentRoute.isSpeaker { - delegate?.audioSessionAdapterDidUpdateCallSettings( - self, - callSettings: activeCallSettings - .withUpdatedSpeakerState(session.currentRoute.isSpeaker) - ) - } - return - } - - switch (activeCallSettings.speakerOn, session.currentRoute.isSpeaker) { - case (true, false): - delegate?.audioSessionAdapterDidUpdateCallSettings( - self, - callSettings: activeCallSettings.withUpdatedSpeakerState(false) - ) - - case (false, true) where session.category == AVAudioSession.Category.playAndRecord.rawValue: - delegate?.audioSessionAdapterDidUpdateCallSettings( - self, - callSettings: activeCallSettings.withUpdatedSpeakerState(true) - ) - - default: - break - } - } - - // MARK: - Private helpers - - private func configureAudioSession(settings: CallSettings) async { - let configuration = settings.audioSessionConfiguration - await audioSession.updateConfiguration( - functionName: #function, - file: #fileID, - line: #line - ) { [weak self] in - guard let self else { - return - } - - try $0.setConfiguration(configuration) - hasBeenConfigured = true - log.debug( - "AudioSession was configured with \(configuration)", - subsystems: .audioSession - ) - } - } - - private func didUpdate( - _ callSettings: CallSettings, - oldValue: CallSettings?, - file: StaticString = #file, - functionName: StaticString = #function, - line: UInt = #line - ) { - serialQueue.async { [weak self] in - guard let self else { - return - } - - guard hasBeenConfigured else { - await configureAudioSession(settings: callSettings) - return - } - - if callSettings.audioOn == false, oldValue?.audioOn == true { - log.debug( - "Will defer execution until recording has stopped.", - subsystems: .audioSession, - functionName: functionName, - fileName: file, - lineNumber: line - ) - await deferExecutionUntilRecordingIsStopped() - } - - let category: AVAudioSession.Category = callSettings.audioOn - || callSettings.speakerOn - || callSettings.videoOn - ? .playAndRecord - : .playback - - let mode: AVAudioSession.Mode = category == .playAndRecord - ? callSettings.speakerOn == true ? .videoChat : .voiceChat - : .default - - let categoryOptions: AVAudioSession.CategoryOptions = category == .playAndRecord - ? .playAndRecord - : .playback - - let overridePort: AVAudioSession.PortOverride? = category == .playAndRecord - ? callSettings.speakerOn == true ? .speaker : AVAudioSession.PortOverride.none - : nil - - await audioSession.updateConfiguration( - functionName: functionName, - file: file, - line: line - ) { [weak self] in - if overridePort == nil, $0.category == AVAudioSession.Category.playAndRecord.rawValue { - try $0.overrideOutputAudioPort(.none) - } - - do { - try $0.setCategory( - category, - mode: mode, - with: categoryOptions - ) - self?.canRecordSubject.send(category == .playAndRecord) - } catch { - log.error( - "Failed while setting category:\(category) mode:\(mode) options:\(categoryOptions)", - subsystems: .audioSession, - error: error, - functionName: functionName, - fileName: file, - lineNumber: line - ) - } - if let overridePort { - try $0.overrideOutputAudioPort(overridePort) - } - } - - log.debug( - "AudioSession updated with callSettings: \(callSettings.description)", - subsystems: .audioSession, - functionName: functionName, - fileName: file, - lineNumber: line - ) - } - } - - private func deferExecutionUntilRecordingIsStopped() async { - do { - _ = try await callAudioRecorder - .isRecordingPublisher - .filter { $0 == false } - .nextValue(timeout: 1) - try await Task.sleep(nanoseconds: 250 * 1_000_000) - } catch { - log.error( - "Defer execution until recording has stopped failed.", - subsystems: .audioSession, - error: error - ) - } - } -} diff --git a/Sources/StreamVideo/Utils/AudioSession/StreamAudioSessionAdapterDelegate.swift b/Sources/StreamVideo/Utils/AudioSession/StreamAudioSessionAdapterDelegate.swift index ef4bdb5e0..2a5e2f3be 100644 --- a/Sources/StreamVideo/Utils/AudioSession/StreamAudioSessionAdapterDelegate.swift +++ b/Sources/StreamVideo/Utils/AudioSession/StreamAudioSessionAdapterDelegate.swift @@ -12,7 +12,7 @@ protocol StreamAudioSessionAdapterDelegate: AnyObject { /// - audioSession: The `AudioSession` instance that made the update. /// - callSettings: The updated `CallSettings`. func audioSessionAdapterDidUpdateCallSettings( - _ adapter: StreamAudioSessionAdapter, + _ adapter: StreamAudioSession, callSettings: CallSettings ) } diff --git a/Sources/StreamVideo/Utils/AudioSession/StreamRTCAudioSession.swift b/Sources/StreamVideo/Utils/AudioSession/StreamRTCAudioSession.swift index 9d04641bb..fc3e273fb 100644 --- a/Sources/StreamVideo/Utils/AudioSession/StreamRTCAudioSession.swift +++ b/Sources/StreamVideo/Utils/AudioSession/StreamRTCAudioSession.swift @@ -3,24 +3,22 @@ // import AVFoundation +import Combine import Foundation import StreamWebRTC /// A class implementing the `AudioSessionProtocol` that manages the WebRTC /// audio session for the application, handling settings and route management. -final class StreamRTCAudioSession: AudioSessionProtocol { +final class StreamRTCAudioSession: @unchecked Sendable, ReflectiveStringConvertible { - private struct State: ReflectiveStringConvertible { + struct State: ReflectiveStringConvertible { var category: AVAudioSession.Category var mode: AVAudioSession.Mode var options: AVAudioSession.CategoryOptions - var overrideInputPort: AVAudioSession.Port? var overrideOutputPort: AVAudioSession.PortOverride = .none } - private var state: State { - didSet { log.debug("AudioSession state updated \(state).", subsystems: .audioSession) } - } + @Published private(set) var state: State /// A queue for processing audio session operations asynchronously. private let processingQueue = SerialActorQueue() @@ -28,6 +26,12 @@ final class StreamRTCAudioSession: AudioSessionProtocol { /// The shared instance of `RTCAudioSession` used for WebRTC audio /// configuration and management. private let source: RTCAudioSession + private let sourceDelegate: RTCAudioSessionDelegatePublisher = .init() + private let disposableBag = DisposableBag() + + var eventPublisher: AnyPublisher { + sourceDelegate.publisher + } /// A Boolean value indicating whether the audio session is currently active. var isActive: Bool { source.isActive } @@ -35,19 +39,6 @@ final class StreamRTCAudioSession: AudioSessionProtocol { /// The current audio route description for the session. var currentRoute: AVAudioSessionRouteDescription { source.currentRoute } - /// The audio category of the session, such as `.playAndRecord`. - var category: String { source.category } - - /// A Boolean value indicating whether the audio session is using - /// the device's speaker. - var isUsingSpeakerOutput: Bool { currentRoute.isSpeaker } - - /// A Boolean value indicating whether the audio session is using - /// an external output, like Bluetooth or headphones. - var isUsingExternalOutput: Bool { currentRoute.isExternal } - - var hasEarpiece: Bool { source.hasEarpiece } - /// A Boolean value indicating whether the audio session uses manual /// audio routing. var useManualAudio: Bool { @@ -61,6 +52,8 @@ final class StreamRTCAudioSession: AudioSessionProtocol { get { source.isAudioEnabled } } + // MARK: - Lifecycle + init() { let source = RTCAudioSession.sharedInstance() self.source = source @@ -69,20 +62,15 @@ final class StreamRTCAudioSession: AudioSessionProtocol { mode: .init(rawValue: source.mode), options: source.categoryOptions ) - } + source.add(sourceDelegate) - /// Adds a delegate to receive updates from the audio session. - /// - Parameter delegate: A delegate conforming to `RTCAudioSessionDelegate`. - func add(_ delegate: RTCAudioSessionDelegate) { - source.add(delegate) + source + .publisher(for: \.category) + .sink { [weak self] in self?.state.category = .init(rawValue: $0) } + .store(in: disposableBag) } - /// Sets the audio mode for the session, such as `.videoChat`. - /// - Parameter mode: The audio mode to set. - /// - Throws: An error if setting the mode fails. - func setMode(_ mode: AVAudioSession.Mode) throws { - try source.setMode(mode) - } + // MARK: - Configuration /// Configures the audio category and category options for the session. /// - Parameters: @@ -93,32 +81,25 @@ final class StreamRTCAudioSession: AudioSessionProtocol { func setCategory( _ category: AVAudioSession.Category, mode: AVAudioSession.Mode, - with categoryOptions: AVAudioSession.CategoryOptions - ) throws { - guard category != state.category - || mode != state.mode - || categoryOptions != state.options - else { - return - } + with categoryOptions: AVAudioSession.CategoryOptions, + file: StaticString = #file, + functionName: StaticString = #function, + line: UInt = #line + ) async throws { + try await performOperation { [weak self] in + guard let self else { + return + } - if category != state.category { - if mode != state.mode { - try source.setCategory( - category, - mode: mode, - options: categoryOptions - ) - try source.setActive(isActive) - } else { - try source.setCategory( - category, - with: categoryOptions - ) + guard category != state.category + || mode != state.mode + || categoryOptions != state.options + else { + return } - } else { - if mode != state.mode { - if categoryOptions != state.options { + + if category != state.category { + if mode != state.mode { try source.setCategory( category, mode: mode, @@ -126,79 +107,75 @@ final class StreamRTCAudioSession: AudioSessionProtocol { ) try source.setActive(isActive) } else { - try source.setMode(mode) + try source.setCategory( + category, + with: categoryOptions + ) } - } else if categoryOptions != state.options { - try source.setCategory( - category, - with: categoryOptions - ) } else { - /* No-op */ + if mode != state.mode { + if categoryOptions != state.options { + try source.setCategory( + category, + mode: mode, + options: categoryOptions + ) + try source.setActive(isActive) + } else { + try source.setMode(mode) + } + } else if categoryOptions != state.options { + try source.setCategory( + category, + with: categoryOptions + ) + } else { + /* No-op */ + } } - } - state.category = category - state.mode = mode - state.options = categoryOptions + state = .init( + category: category, + mode: mode, + options: categoryOptions, + overrideOutputPort: state.overrideOutputPort + ) + } } /// Activates or deactivates the audio session. /// - Parameter isActive: A Boolean indicating whether the session /// should be active. /// - Throws: An error if activation or deactivation fails. - func setActive(_ isActive: Bool) throws { - try source.setActive(isActive) - } + func setActive( + _ isActive: Bool + ) async throws { + try await performOperation { [weak self] in + guard let self else { + return + } - /// Sets the audio configuration for the WebRTC session. - /// - Parameter configuration: The configuration to apply. - /// - Throws: An error if setting the configuration fails. - func setConfiguration(_ configuration: RTCAudioSessionConfiguration) throws { - try source.setConfiguration(configuration) - state.category = .init(rawValue: configuration.category) - state.mode = .init(rawValue: configuration.mode) - state.options = configuration.categoryOptions + try source.setActive(isActive) + } } /// Overrides the audio output port, such as switching to speaker output. /// - Parameter port: The output port to use, such as `.speaker`. /// - Throws: An error if overriding the output port fails. - func overrideOutputAudioPort(_ port: AVAudioSession.PortOverride) throws { - guard state.overrideOutputPort != port else { - return - } - try source.overrideOutputAudioPort(port) - state.overrideOutputPort = port - } + func overrideOutputAudioPort( + _ port: AVAudioSession.PortOverride + ) async throws { + try await performOperation { [weak self] in + guard let self else { + return + } - /// Performs an asynchronous update to the audio session configuration. - /// - Parameters: - /// - functionName: The name of the calling function. - /// - file: The source file of the calling function. - /// - line: The line number of the calling function. - /// - block: A closure that performs an audio configuration update. - func updateConfiguration( - functionName: StaticString, - file: StaticString, - line: UInt, - _ block: @escaping (any AudioSessionProtocol) throws -> Void - ) async { - try? await processingQueue.sync { [weak self] in - guard let self else { return } - source.lockForConfiguration() - defer { source.unlockForConfiguration() } - do { - try block(self) - } catch { - log.error( - error, - subsystems: .audioSession, - functionName: functionName, - fileName: file, - lineNumber: line - ) + guard state.overrideOutputPort != port else { + return } + + try source.overrideOutputAudioPort(port) + state.overrideOutputPort = port } } @@ -211,23 +188,17 @@ final class StreamRTCAudioSession: AudioSessionProtocol { } } } -} -/// A key for dependency injection of an `AudioSessionProtocol` instance -/// that represents the active call audio session. -struct StreamActiveCallAudioSessionKey: InjectionKey { - static var currentValue: StreamAudioSessionAdapter? -} + // MARK: - Private Helpers -extension InjectedValues { - /// The active call's audio session. The value is being set on `StreamAudioSessionAdapter` - /// `init` / `deinit` - var activeCallAudioSession: StreamAudioSessionAdapter? { - get { - Self[StreamActiveCallAudioSessionKey.self] - } - set { - Self[StreamActiveCallAudioSessionKey.self] = newValue + private func performOperation( + _ operation: @Sendable @escaping () async throws -> Void + ) async throws { + try await processingQueue.sync { [weak self] in + guard let self else { return } + source.lockForConfiguration() + defer { source.unlockForConfiguration() } + try await operation() } } } diff --git a/Sources/StreamVideo/Utils/CurrentDevice/CurrentDevice.swift b/Sources/StreamVideo/Utils/CurrentDevice/CurrentDevice.swift new file mode 100644 index 000000000..02043dab1 --- /dev/null +++ b/Sources/StreamVideo/Utils/CurrentDevice/CurrentDevice.swift @@ -0,0 +1,42 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +final class CurrentDevice { + enum DeviceType { case unspecified, phone, pad, tv, carPlay, mac, vision } + + let deviceType: DeviceType + + private init() { + #if canImport(UIKit) + deviceType = switch UIDevice.current.userInterfaceIdiom { + case .unspecified: .unspecified + case .phone: .phone + case .pad: .pad + case .tv: .tv + case .carPlay: .carPlay + case .mac: .mac + case .vision: .vision + @unknown default: .unspecified + } + #elseif canImport(AppKit) + deviceType = .mac + #else + deviceType = .unspecified + #endif + } +} + +extension CurrentDevice: InjectionKey { + static var currentValue: CurrentDevice = .init() +} + +extension InjectedValues { + var currentDevice: CurrentDevice { Self[CurrentDevice.self] } +} diff --git a/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Publisher+TaskSink.swift b/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Publisher+TaskSink.swift index ac3097679..8ac247ef4 100644 --- a/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Publisher+TaskSink.swift +++ b/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Publisher+TaskSink.swift @@ -50,7 +50,7 @@ extension Publisher { } } catch { // Log any unexpected errors during task execution. - LogConfig.logger.error(error) + LogConfig.logger.error(ClientError(with: error)) } } diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift index 33b62a721..64d995fbe 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift @@ -42,7 +42,7 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate { let peerConnectionFactory: PeerConnectionFactory let videoCaptureSessionProvider: VideoCaptureSessionProvider let screenShareSessionProvider: ScreenShareSessionProvider - let audioSession: StreamAudioSessionAdapter = .init(callSettings: .init()) + let audioSession: StreamAudioSession = .init(callSettings: .init()) /// Published properties that represent different parts of the WebRTC state. @Published private(set) var sessionID: String = UUID().uuidString @@ -122,7 +122,13 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate { Task { await $callSettings .removeDuplicates() - .sink { [weak audioSession] in audioSession?.didUpdateCallSettings($0) } + .sinkTask { [weak audioSession] in + do { + try await audioSession?.didUpdateCallSettings($0) + } catch { + log.error(error) + } + } .store(in: disposableBag) } } @@ -571,7 +577,7 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate { // MARK: - AudioSessionDelegate nonisolated func audioSessionAdapterDidUpdateCallSettings( - _ adapter: StreamAudioSessionAdapter, + _ adapter: StreamAudioSession, callSettings: CallSettings ) { Task { diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index 61e6504b7..9d5b6078f 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -44,7 +44,7 @@ 40149DC32B7E202600473176 /* ParticipantEventViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40149DC22B7E202600473176 /* ParticipantEventViewModifier.swift */; }; 40149DCC2B7E814300473176 /* AVAudioRecorderBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40149DCB2B7E814300473176 /* AVAudioRecorderBuilder.swift */; }; 40149DCE2B7E837A00473176 /* StreamCallAudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40149DCD2B7E837A00473176 /* StreamCallAudioRecorder.swift */; }; - 40149DD02B7E839500473176 /* AudioSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40149DCF2B7E839500473176 /* AudioSessionProtocol.swift */; }; + 40149DD02B7E839500473176 /* AVAudioSession+RequestRecordPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40149DCF2B7E839500473176 /* AVAudioSession+RequestRecordPermission.swift */; }; 401A0F032AB1C1B600BE2DBD /* ThermalStateObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A0F022AB1C1B600BE2DBD /* ThermalStateObserver.swift */; }; 401A64A52A9DF79E00534ED1 /* StreamChatSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 401A64A42A9DF79E00534ED1 /* StreamChatSwiftUI */; }; 401A64A82A9DF7B400534ED1 /* EffectsLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 401A64A72A9DF7B400534ED1 /* EffectsLibrary */; }; @@ -233,7 +233,7 @@ 4067F3112CDA33AB002E28BD /* AVAudioSessionPortDescription+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4067F3102CDA33AB002E28BD /* AVAudioSessionPortDescription+Convenience.swift */; }; 4067F3132CDA33C6002E28BD /* RTCAudioSessionConfiguration+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4067F3122CDA33C4002E28BD /* RTCAudioSessionConfiguration+Default.swift */; }; 4067F3152CDA4094002E28BD /* StreamRTCAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4067F3142CDA4094002E28BD /* StreamRTCAudioSession.swift */; }; - 4067F3172CDA40CC002E28BD /* StreamAudioSessionAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4067F3162CDA40CC002E28BD /* StreamAudioSessionAdapter.swift */; }; + 4067F3172CDA40CC002E28BD /* StreamAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4067F3162CDA40CC002E28BD /* StreamAudioSession.swift */; }; 4067F3192CDA469F002E28BD /* MockAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4067F3182CDA469C002E28BD /* MockAudioSession.swift */; }; 4067F31C2CDA55D6002E28BD /* StreamRTCAudioSession_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4067F31B2CDA55D6002E28BD /* StreamRTCAudioSession_Tests.swift */; }; 4067F31E2CDA5A56002E28BD /* StreamAudioSessionAdapter_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4067F31D2CDA5A53002E28BD /* StreamAudioSessionAdapter_Tests.swift */; }; @@ -612,6 +612,8 @@ 40E363712D0A27640028C52A /* BroadcastCaptureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E363702D0A27640028C52A /* BroadcastCaptureHandler.swift */; }; 40E363752D0A2C6B0028C52A /* CGSize+Adapt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E363742D0A2C6B0028C52A /* CGSize+Adapt.swift */; }; 40E363772D0A2E320028C52A /* BroadcastBufferReaderKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E363762D0A2E320028C52A /* BroadcastBufferReaderKey.swift */; }; + 40E741FA2D54E6F40044C955 /* RTCAudioSessionDelegatePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E741F92D54E6F40044C955 /* RTCAudioSessionDelegatePublisher.swift */; }; + 40E741FF2D553ACD0044C955 /* CurrentDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E741FE2D553ACD0044C955 /* CurrentDevice.swift */; }; 40E9B3B12BCD755F00ACF18F /* MemberResponse+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E9B3B02BCD755F00ACF18F /* MemberResponse+Dummy.swift */; }; 40E9B3B32BCD93AE00ACF18F /* JoinCallResponse+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E9B3B22BCD93AE00ACF18F /* JoinCallResponse+Dummy.swift */; }; 40E9B3B52BCD93F500ACF18F /* Credentials+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E9B3B42BCD93F500ACF18F /* Credentials+Dummy.swift */; }; @@ -1560,7 +1562,7 @@ 40149DC22B7E202600473176 /* ParticipantEventViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantEventViewModifier.swift; sourceTree = ""; }; 40149DCB2B7E814300473176 /* AVAudioRecorderBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVAudioRecorderBuilder.swift; sourceTree = ""; }; 40149DCD2B7E837A00473176 /* StreamCallAudioRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamCallAudioRecorder.swift; sourceTree = ""; }; - 40149DCF2B7E839500473176 /* AudioSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionProtocol.swift; sourceTree = ""; }; + 40149DCF2B7E839500473176 /* AVAudioSession+RequestRecordPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVAudioSession+RequestRecordPermission.swift"; sourceTree = ""; }; 401A0F022AB1C1B600BE2DBD /* ThermalStateObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalStateObserver.swift; sourceTree = ""; }; 401A64AA2A9DF7EC00534ED1 /* DemoChatAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoChatAdapter.swift; sourceTree = ""; }; 401A64B02A9DF83200534ED1 /* TokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenResponse.swift; sourceTree = ""; }; @@ -1693,7 +1695,7 @@ 4067F3102CDA33AB002E28BD /* AVAudioSessionPortDescription+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVAudioSessionPortDescription+Convenience.swift"; sourceTree = ""; }; 4067F3122CDA33C4002E28BD /* RTCAudioSessionConfiguration+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RTCAudioSessionConfiguration+Default.swift"; sourceTree = ""; }; 4067F3142CDA4094002E28BD /* StreamRTCAudioSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamRTCAudioSession.swift; sourceTree = ""; }; - 4067F3162CDA40CC002E28BD /* StreamAudioSessionAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamAudioSessionAdapter.swift; sourceTree = ""; }; + 4067F3162CDA40CC002E28BD /* StreamAudioSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamAudioSession.swift; sourceTree = ""; }; 4067F3182CDA469C002E28BD /* MockAudioSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAudioSession.swift; sourceTree = ""; }; 4067F31B2CDA55D6002E28BD /* StreamRTCAudioSession_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamRTCAudioSession_Tests.swift; sourceTree = ""; }; 4067F31D2CDA5A53002E28BD /* StreamAudioSessionAdapter_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamAudioSessionAdapter_Tests.swift; sourceTree = ""; }; @@ -1993,6 +1995,8 @@ 40E363702D0A27640028C52A /* BroadcastCaptureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastCaptureHandler.swift; sourceTree = ""; }; 40E363742D0A2C6B0028C52A /* CGSize+Adapt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGSize+Adapt.swift"; sourceTree = ""; }; 40E363762D0A2E320028C52A /* BroadcastBufferReaderKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastBufferReaderKey.swift; sourceTree = ""; }; + 40E741F92D54E6F40044C955 /* RTCAudioSessionDelegatePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCAudioSessionDelegatePublisher.swift; sourceTree = ""; }; + 40E741FE2D553ACD0044C955 /* CurrentDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentDevice.swift; sourceTree = ""; }; 40E9B3B02BCD755F00ACF18F /* MemberResponse+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MemberResponse+Dummy.swift"; sourceTree = ""; }; 40E9B3B22BCD93AE00ACF18F /* JoinCallResponse+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JoinCallResponse+Dummy.swift"; sourceTree = ""; }; 40E9B3B42BCD93F500ACF18F /* Credentials+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Credentials+Dummy.swift"; sourceTree = ""; }; @@ -3409,9 +3413,9 @@ children = ( 4067F3092CDA330E002E28BD /* Extensions */, 40149DCA2B7E813500473176 /* AudioRecorder */, - 40149DCF2B7E839500473176 /* AudioSessionProtocol.swift */, 4067F3142CDA4094002E28BD /* StreamRTCAudioSession.swift */, - 4067F3162CDA40CC002E28BD /* StreamAudioSessionAdapter.swift */, + 4067F3162CDA40CC002E28BD /* StreamAudioSession.swift */, + 40E741F92D54E6F40044C955 /* RTCAudioSessionDelegatePublisher.swift */, 4067F3072CDA32FA002E28BD /* StreamAudioSessionAdapterDelegate.swift */, ); path = AudioSession; @@ -3420,6 +3424,7 @@ 4067F3092CDA330E002E28BD /* Extensions */ = { isa = PBXGroup; children = ( + 40149DCF2B7E839500473176 /* AVAudioSession+RequestRecordPermission.swift */, 4067F3122CDA33C4002E28BD /* RTCAudioSessionConfiguration+Default.swift */, 4067F3102CDA33AB002E28BD /* AVAudioSessionPortDescription+Convenience.swift */, 4067F30E2CDA3394002E28BD /* AVAudioSessionCategoryOptions+Convenience.swift */, @@ -4432,6 +4437,14 @@ path = Broadcast; sourceTree = ""; }; + 40E741FD2D553AB40044C955 /* CurrentDevice */ = { + isa = PBXGroup; + children = ( + 40E741FE2D553ACD0044C955 /* CurrentDevice.swift */, + ); + path = CurrentDevice; + sourceTree = ""; + }; 40F0173C2BBEB85F00E89FD1 /* Utilities */ = { isa = PBXGroup; children = ( @@ -5433,6 +5446,7 @@ 84AF64D3287C79220012A503 /* Utils */ = { isa = PBXGroup; children = ( + 40E741FD2D553AB40044C955 /* CurrentDevice */, 401C1EE92D4900BA00304609 /* ClosedCaptionsAdapter */, 401C1EE32D48F09100304609 /* AyncStreamPublisher */, 401C1EDF2D48EB9800304609 /* OrderedCapacityQueue */, @@ -6800,7 +6814,7 @@ 8449824E2C738A830029734D /* StopAllRTMPBroadcastsResponse.swift in Sources */, 40E363522D0A11620028C52A /* AVCaptureDevice+OutputFormat.swift in Sources */, 84D2E37729DC856D001D2118 /* CallMemberUpdatedEvent.swift in Sources */, - 40149DD02B7E839500473176 /* AudioSessionProtocol.swift in Sources */, + 40149DD02B7E839500473176 /* AVAudioSession+RequestRecordPermission.swift in Sources */, 40DFA88D2CC10FF3003DCE05 /* Stream_Video_Sfu_Models_AppleThermalState+Convenience.swift in Sources */, 8409465B29AF4EEC007AF5BF /* ListRecordingsResponse.swift in Sources */, 8490DD21298D4ADF007E53D2 /* StreamJsonDecoder.swift in Sources */, @@ -6958,7 +6972,7 @@ 40C4DF492C1C2C210035DBC2 /* Publisher+WeakAssign.swift in Sources */, 4157FF912C9AC9EC0093D839 /* RTMPBroadcastRequest.swift in Sources */, 844982472C738A830029734D /* DeleteRecordingResponse.swift in Sources */, - 4067F3172CDA40CC002E28BD /* StreamAudioSessionAdapter.swift in Sources */, + 4067F3172CDA40CC002E28BD /* StreamAudioSession.swift in Sources */, 40AB34DA2C5D5A7B00B5B6B3 /* WebRTCStatsReporter.swift in Sources */, 408679F72BD12F1000D027E0 /* AudioFilter.swift in Sources */, 8456E6D2287EC343004E180E /* ConsoleLogDestination.swift in Sources */, @@ -7053,6 +7067,7 @@ 4039F0CC2D0241120078159E /* AudioCodec.swift in Sources */, 4097B3832BF4E37B0057992D /* OnChangeViewModifier_iOS13.swift in Sources */, 84A7E1862883632100526C98 /* ConnectionStatus.swift in Sources */, + 40E741FA2D54E6F40044C955 /* RTCAudioSessionDelegatePublisher.swift in Sources */, 841BAA472BD15CDE000C73E4 /* CallTranscriptionReadyEvent.swift in Sources */, 4012B1922BFCA518006B0031 /* StreamCallStateMachine+AcceptingStage.swift in Sources */, 841BAA352BD15CDE000C73E4 /* CallTranscriptionStartedEvent.swift in Sources */, @@ -7155,6 +7170,7 @@ 842E70D92B91BE1700D2D68B /* CallClosedCaption.swift in Sources */, 84FC2C2428AD1B5E00181490 /* WebRTCEventDecoder.swift in Sources */, 40149DCE2B7E837A00473176 /* StreamCallAudioRecorder.swift in Sources */, + 40E741FF2D553ACD0044C955 /* CurrentDevice.swift in Sources */, 84DC389B29ADFCFD00946713 /* PermissionRequestEvent.swift in Sources */, 406B3C432C91E41400FC93A1 /* WebRTCAuthenticator.swift in Sources */, 84BAD77A2A6BFEF900733156 /* BroadcastBufferUploader.swift in Sources */,