diff --git a/ios/RCTWebRTC/Utils/AudioDeviceModule/AudioDeviceModule.swift b/ios/RCTWebRTC/Utils/AudioDeviceModule/AudioDeviceModule.swift new file mode 100644 index 000000000..eaa25ab20 --- /dev/null +++ b/ios/RCTWebRTC/Utils/AudioDeviceModule/AudioDeviceModule.swift @@ -0,0 +1,572 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import AudioToolbox +import AVFAudio +import AVFoundation +import Combine +import Foundation +import WebRTC + +/// Bridges `RTCAudioDeviceModule` callbacks to Combine-based state so the +/// audio pipeline can stay in sync with application logic. +@objc public final class AudioDeviceModule: NSObject, RTCAudioDeviceModuleDelegate, Encodable, @unchecked Sendable { + + /// Audio device module errors + enum AudioDeviceError: Error { + case operationFailed(String, Int) + + var localizedDescription: String { + switch self { + case .operationFailed(let message, let code): + return "\(message) (Error code: \(code))" + } + } + } + + /// Helper constants used across the module. + enum Constant { + /// WebRTC interfaces return integer result codes. We use this typed/named + /// constant to define the success of an operation. + static let successResult = 0 + + /// Audio pipeline floor in dB that we interpret as silence. + static let silenceDB: Float = -160 + } + + /// Events emitted as the underlying audio engine changes state. + enum Event: Equatable, CustomStringConvertible { + /// Outbound audio surpassed the silence threshold. + case speechActivityStarted + /// Outbound audio dropped back to silence. + case speechActivityEnded + /// A new `AVAudioEngine` instance has been created. + case didCreateAudioEngine(AVAudioEngine) + /// The engine is about to enable playout/recording paths. + case willEnableAudioEngine(AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) + /// The engine is about to start rendering. + case willStartAudioEngine(AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) + /// The engine has fully stopped. + case didStopAudioEngine(AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) + /// The engine was disabled after stopping. + case didDisableAudioEngine(AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) + /// The engine will be torn down. + case willReleaseAudioEngine(AVAudioEngine) + /// The input graph is configured with a new source node. + case configureInputFromSource(AVAudioEngine, source: AVAudioNode?, destination: AVAudioNode, format: AVAudioFormat) + /// The output graph is configured with a destination node. + case configureOutputFromSource(AVAudioEngine, source: AVAudioNode, destination: AVAudioNode?, format: AVAudioFormat) + /// Voice processing knobs changed. + case didUpdateAudioProcessingState( + voiceProcessingEnabled: Bool, + voiceProcessingBypassed: Bool, + voiceProcessingAGCEnabled: Bool, + stereoPlayoutEnabled: Bool + ) + + var description: String { + switch self { + case .speechActivityStarted: + return ".speechActivityStarted" + + case .speechActivityEnded: + return ".speechActivityEnded" + + case .didCreateAudioEngine(let engine): + return ".didCreateAudioEngine(\(engine))" + + case .willEnableAudioEngine(let engine, let isPlayoutEnabled, let isRecordingEnabled): + return ".willEnableAudioEngine(\(engine), isPlayoutEnabled:\(isPlayoutEnabled), isRecordingEnabled:\(isRecordingEnabled))" + + case .willStartAudioEngine(let engine, let isPlayoutEnabled, let isRecordingEnabled): + return ".willStartAudioEngine(\(engine), isPlayoutEnabled:\(isPlayoutEnabled), isRecordingEnabled:\(isRecordingEnabled))" + + case .didStopAudioEngine(let engine, let isPlayoutEnabled, let isRecordingEnabled): + return ".didStopAudioEngine(\(engine), isPlayoutEnabled:\(isPlayoutEnabled), isRecordingEnabled:\(isRecordingEnabled))" + + case .didDisableAudioEngine(let engine, let isPlayoutEnabled, let isRecordingEnabled): + return ".didDisableAudioEngine(\(engine), isPlayoutEnabled:\(isPlayoutEnabled), isRecordingEnabled:\(isRecordingEnabled))" + + case .willReleaseAudioEngine(let engine): + return ".willReleaseAudioEngine(\(engine))" + + case .configureInputFromSource(let engine, let source, let destination, let format): + return ".configureInputFromSource(\(engine), source:\(source), destination:\(destination), format:\(format))" + + case .configureOutputFromSource(let engine, let source, let destination, let format): + return ".configureOutputFromSource(\(engine), source:\(source), destination:\(destination), format:\(format))" + + case let .didUpdateAudioProcessingState( + voiceProcessingEnabled, + voiceProcessingBypassed, + voiceProcessingAGCEnabled, + stereoPlayoutEnabled + ): + return ".didUpdateAudioProcessingState(voiceProcessingEnabled:\(voiceProcessingEnabled), voiceProcessingBypassed:\(voiceProcessingBypassed), voiceProcessingAGCEnabled:\(voiceProcessingAGCEnabled), stereoPlayoutEnabled:\(stereoPlayoutEnabled))" + } + } + } + + /// Tracks whether WebRTC is currently playing back audio. + private let isPlayingSubject: CurrentValueSubject + /// `true` while audio playout is active. + @objc public var isPlaying: Bool { isPlayingSubject.value } + /// Publisher that reflects playout activity changes. + var isPlayingPublisher: AnyPublisher { isPlayingSubject.eraseToAnyPublisher() } + + /// Tracks whether WebRTC is capturing microphone samples. + private let isRecordingSubject: CurrentValueSubject + /// `true` while audio capture is active. + @objc public var isRecording: Bool { isRecordingSubject.value } + /// Publisher that reflects recording activity changes. + var isRecordingPublisher: AnyPublisher { isRecordingSubject.eraseToAnyPublisher() } + + /// Tracks whether the microphone is muted at the ADM layer. + private let isMicrophoneMutedSubject: CurrentValueSubject + /// `true` if the microphone is muted. + @objc public var isMicrophoneMuted: Bool { isMicrophoneMutedSubject.value } + /// Publisher that reflects microphone mute changes. + var isMicrophoneMutedPublisher: AnyPublisher { isMicrophoneMutedSubject.eraseToAnyPublisher() } + + /// Tracks whether stereo playout is configured. + private let isStereoPlayoutEnabledSubject: CurrentValueSubject + /// `true` if stereo playout is available and active. + @objc public var isStereoPlayoutEnabled: Bool { isStereoPlayoutEnabledSubject.value } + /// Publisher emitting stereo playout state. + var isStereoPlayoutEnabledPublisher: AnyPublisher { isStereoPlayoutEnabledSubject.eraseToAnyPublisher() } + + /// Tracks whether VP processing is currently bypassed. + private let isVoiceProcessingBypassedSubject: CurrentValueSubject + /// `true` if the voice processing unit is bypassed. + @objc public var isVoiceProcessingBypassed: Bool { isVoiceProcessingBypassedSubject.value } + /// Publisher emitting VP bypass changes. + var isVoiceProcessingBypassedPublisher: AnyPublisher { isVoiceProcessingBypassedSubject.eraseToAnyPublisher() } + + /// Tracks whether voice processing is enabled. + private let isVoiceProcessingEnabledSubject: CurrentValueSubject + /// `true` when Apple VP is active. + @objc public var isVoiceProcessingEnabled: Bool { isVoiceProcessingEnabledSubject.value } + /// Publisher emitting VP enablement changes. + var isVoiceProcessingEnabledPublisher: AnyPublisher { isVoiceProcessingEnabledSubject.eraseToAnyPublisher() } + + /// Tracks whether automatic gain control is enabled inside VP. + private let isVoiceProcessingAGCEnabledSubject: CurrentValueSubject + /// `true` while AGC is active. + @objc public var isVoiceProcessingAGCEnabled: Bool { isVoiceProcessingAGCEnabledSubject.value } + /// Publisher emitting AGC changes. + var isVoiceProcessingAGCEnabledPublisher: AnyPublisher { isVoiceProcessingAGCEnabledSubject.eraseToAnyPublisher() } + + /// Observes RMS audio levels (in dB) derived from the input tap. + private let audioLevelSubject = CurrentValueSubject(Constant.silenceDB) // default to silence + /// Latest measured audio level. + @objc public var audioLevel: Float { audioLevelSubject.value } + /// Publisher emitting audio level updates. + var audioLevelPublisher: AnyPublisher { audioLevelSubject.eraseToAnyPublisher() } + + /// Wrapper around WebRTC `RTCAudioDeviceModule`. + private let source: any RTCAudioDeviceModuleControlling + + /// Serial queue used to deliver events to observers. + private let dispatchQueue: DispatchQueue + /// Internal relay that feeds `publisher`. + private let subject: PassthroughSubject + /// Object that taps engine nodes and publishes audio level data. + private var audioLevelsAdapter: AudioEngineNodeAdapting + /// Public stream of `Event` values describing engine transitions. + let publisher: AnyPublisher + + /// Strong reference to the current engine so we can introspect it if needed. + @objc public var engine: AVAudioEngine? + + /// Textual diagnostics for logging and debugging. + @objc public override var description: String { + "{ " + + "isPlaying:\(isPlaying)" + + ", isRecording:\(isRecording)" + + ", isMicrophoneMuted:\(isMicrophoneMuted)" + + ", isStereoPlayoutEnabled:\(isStereoPlayoutEnabled)" + + ", isVoiceProcessingBypassed:\(isVoiceProcessingBypassed)" + + ", isVoiceProcessingEnabled:\(isVoiceProcessingEnabled)" + + ", isVoiceProcessingAGCEnabled:\(isVoiceProcessingAGCEnabled)" + + ", audioLevel:\(audioLevel)" + + ", source:\(source)" + + " }" + } + + /// Creates a module that mirrors the provided WebRTC audio device module. + /// - Parameter source: The audio device module implementation to observe. + init( + _ source: any RTCAudioDeviceModuleControlling, + audioLevelsNodeAdapter: AudioEngineNodeAdapting = AudioEngineLevelNodeAdapter() + ) { + self.source = source + self.isPlayingSubject = .init(source.isPlaying) + self.isRecordingSubject = .init(source.isRecording) + self.isMicrophoneMutedSubject = .init(source.isMicrophoneMuted) + self.isStereoPlayoutEnabledSubject = .init(source.isStereoPlayoutEnabled) + self.isVoiceProcessingBypassedSubject = .init(source.isVoiceProcessingBypassed) + self.isVoiceProcessingEnabledSubject = .init(source.isVoiceProcessingEnabled) + self.isVoiceProcessingAGCEnabledSubject = .init(source.isVoiceProcessingAGCEnabled) + self.audioLevelsAdapter = audioLevelsNodeAdapter + + let dispatchQueue = DispatchQueue(label: "io.getstream.audiodevicemodule", qos: .userInteractive) + let subject = PassthroughSubject() + self.subject = subject + self.dispatchQueue = dispatchQueue + self.publisher = subject + .receive(on: dispatchQueue) + .eraseToAnyPublisher() + super.init() + + audioLevelsAdapter.subject = audioLevelSubject + source.observer = self + + source.isVoiceProcessingBypassed = true + } + + /// Objective-C compatible convenience initializer. + /// - Parameter source: The RTCAudioDeviceModule to wrap. + @objc public + convenience init(source: RTCAudioDeviceModule) { + self.init(source as any RTCAudioDeviceModuleControlling, audioLevelsNodeAdapter: AudioEngineLevelNodeAdapter()) + } + + // MARK: - Recording + + /// Reinitializes the ADM, clearing its internal audio graph state. + @objc public func reset() { + _ = source.reset() + } + + /// Switches between stereo and mono playout while keeping the recording + /// state consistent across reinitializations. + /// - Parameter isPreferred: `true` when stereo output should be used. + @objc public func setStereoPlayoutPreference(_ isPreferred: Bool) { + /// - Important: `.voiceProcessing` requires VP to be enabled in order to mute and + /// `.restartEngine` rebuilds the whole graph. Each of them has different issues: + /// - `.voiceProcessing`: as it requires VP to be enabled in order to mute/unmute that + /// means that for outputs where VP is disabled (e.g. stereo) we cannot mute/unmute. + /// - `.restartEngine`: rebuilds the whole graph and requires explicit calling of + /// `initAndStartRecording` . + _ = source.setMuteMode(isPreferred ? .inputMixer : .voiceProcessing) + /// - Important: We can probably set this one to false when the user doesn't have + /// sendAudio capability. + _ = source.setRecordingAlwaysPreparedMode(false) + source.prefersStereoPlayout = isPreferred + } + + /// Starts or stops speaker playout on the ADM, retrying transient failures. + /// - Parameter isActive: `true` to start playout, `false` to stop. + /// - Throws: `AudioDeviceError` when WebRTC returns a non-zero status. + @objc public func setPlayout(_ isActive: Bool) throws { + guard isActive != isPlaying else { + return + } + if isActive { + if source.isPlayoutInitialized { + try throwingExecution("Unable to start playout") { + source.startPlayout() + } + } else { + try throwingExecution("Unable to initAndStart playout") { + source.initAndStartPlayout() + } + } + } else { + try throwingExecution("Unable to stop playout") { + source.stopPlayout() + } + } + } + + /// Enables or disables recording on the wrapped audio device module. + /// - Parameter isEnabled: When `true` recording starts, otherwise stops. + /// - Throws: `AudioDeviceError` when the underlying module reports a failure. + @objc public func setRecording(_ isEnabled: Bool) throws { + guard isEnabled != isRecording else { + return + } + if isEnabled { + if source.isRecordingInitialized { + try throwingExecution("Unable to start recording") { + source.startRecording() + } + } else { + try throwingExecution("Unable to initAndStart recording") { + source.initAndStartRecording() + } + } + } else { + try throwingExecution("Unable to stop recording") { + source.stopRecording() + } + } + + isRecordingSubject.send(isEnabled) + } + + /// Updates the muted state of the microphone for the wrapped module. + /// - Parameter isMuted: `true` to mute the microphone, `false` to unmute. + /// - Throws: `AudioDeviceError` when the underlying module reports a failure. + @objc public func setMuted(_ isMuted: Bool) throws { + guard isMuted != source.isMicrophoneMuted else { + return + } + + if !isMuted, !isRecording { + try setRecording(true) + } + + try throwingExecution("Unable to setMicrophoneMuted:\(isMuted)") { + source.setMicrophoneMuted(isMuted) + } + + isMicrophoneMutedSubject.send(isMuted) + } + + /// Forces the ADM to recompute whether stereo output is supported. + @objc public func refreshStereoPlayoutState() { + source.refreshStereoPlayoutState() + } + + // MARK: - RTCAudioDeviceModuleDelegate + + /// Receives speech activity notifications emitted by WebRTC VAD. + public func audioDeviceModule( + _ audioDeviceModule: RTCAudioDeviceModule, + didReceiveSpeechActivityEvent speechActivityEvent: RTCSpeechActivityEvent + ) { + switch speechActivityEvent { + case .started: + subject.send(.speechActivityStarted) + case .ended: + subject.send(.speechActivityEnded) + @unknown default: + break + } + } + + /// Stores the created engine reference and emits an event so observers can + /// hook into the audio graph configuration. + public func audioDeviceModule( + _ audioDeviceModule: RTCAudioDeviceModule, + didCreateEngine engine: AVAudioEngine + ) -> Int { + self.engine = engine + subject.send(.didCreateAudioEngine(engine)) + return Constant.successResult + } + + /// Keeps local playback/recording state in sync as WebRTC enables the + /// corresponding engine paths. + public func audioDeviceModule( + _ audioDeviceModule: RTCAudioDeviceModule, + willEnableEngine engine: AVAudioEngine, + isPlayoutEnabled: Bool, + isRecordingEnabled: Bool + ) -> Int { + subject.send( + .willEnableAudioEngine( + engine, + isPlayoutEnabled: isPlayoutEnabled, + isRecordingEnabled: isRecordingEnabled + ) + ) + isPlayingSubject.send(isPlayoutEnabled) + isRecordingSubject.send(isRecordingEnabled) + return Constant.successResult + } + + /// Mirrors state when the engine is about to start running and delivering + /// audio samples. + public func audioDeviceModule( + _ audioDeviceModule: RTCAudioDeviceModule, + willStartEngine engine: AVAudioEngine, + isPlayoutEnabled: Bool, + isRecordingEnabled: Bool + ) -> Int { + subject.send( + .willStartAudioEngine( + engine, + isPlayoutEnabled: isPlayoutEnabled, + isRecordingEnabled: isRecordingEnabled + ) + ) + isPlayingSubject.send(isPlayoutEnabled) + isRecordingSubject.send(isRecordingEnabled) + + return Constant.successResult + } + + /// Updates state and notifies observers once the engine has completely + /// stopped. + public func audioDeviceModule( + _ audioDeviceModule: RTCAudioDeviceModule, + didStopEngine engine: AVAudioEngine, + isPlayoutEnabled: Bool, + isRecordingEnabled: Bool + ) -> Int { + subject.send( + .didStopAudioEngine( + engine, + isPlayoutEnabled: isPlayoutEnabled, + isRecordingEnabled: isRecordingEnabled + ) + ) + isPlayingSubject.send(isPlayoutEnabled) + isRecordingSubject.send(isRecordingEnabled) + return Constant.successResult + } + + /// Tracks when the engine has been disabled after stopping so clients can + /// react (e.g., rebuilding audio graphs). + public func audioDeviceModule( + _ audioDeviceModule: RTCAudioDeviceModule, + didDisableEngine engine: AVAudioEngine, + isPlayoutEnabled: Bool, + isRecordingEnabled: Bool + ) -> Int { + subject.send( + .didDisableAudioEngine( + engine, + isPlayoutEnabled: isPlayoutEnabled, + isRecordingEnabled: isRecordingEnabled + ) + ) + isPlayingSubject.send(isPlayoutEnabled) + isRecordingSubject.send(isRecordingEnabled) + return Constant.successResult + } + + /// Clears internal references before WebRTC disposes the engine. + public func audioDeviceModule( + _ audioDeviceModule: RTCAudioDeviceModule, + willReleaseEngine engine: AVAudioEngine + ) -> Int { + self.engine = nil + subject.send(.willReleaseAudioEngine(engine)) + audioLevelsAdapter.uninstall(on: 0) + return Constant.successResult + } + + /// Keeps observers informed when WebRTC sets up the input graph and installs + /// an audio level tap to monitor microphone activity. + public func audioDeviceModule( + _ audioDeviceModule: RTCAudioDeviceModule, + engine: AVAudioEngine, + configureInputFromSource source: AVAudioNode?, + toDestination destination: AVAudioNode, + format: AVAudioFormat, + context: [AnyHashable: Any] + ) -> Int { + subject.send( + .configureInputFromSource( + engine, + source: source, + destination: destination, + format: format + ) + ) + audioLevelsAdapter.installInputTap( + on: destination, + format: format, + bus: 0, + bufferSize: 1024 + ) + return Constant.successResult + } + + /// Emits an event whenever WebRTC reconfigures the output graph. + public func audioDeviceModule( + _ audioDeviceModule: RTCAudioDeviceModule, + engine: AVAudioEngine, + configureOutputFromSource source: AVAudioNode, + toDestination destination: AVAudioNode?, + format: AVAudioFormat, + context: [AnyHashable: Any] + ) -> Int { + subject.send( + .configureOutputFromSource( + engine, + source: source, + destination: destination, + format: format + ) + ) + return Constant.successResult + } + + /// Currently unused: CallKit/RoutePicker own the device selection UX. + public func audioDeviceModuleDidUpdateDevices( + _ audioDeviceModule: RTCAudioDeviceModule + ) { + // No-op + } + + /// Mirrors state changes coming from CallKit/WebRTC voice-processing + /// controls so UI can reflect the correct toggles. + public func audioDeviceModule( + _ module: RTCAudioDeviceModule, + didUpdateAudioProcessingState state: RTCAudioProcessingState + ) { + subject.send( + .didUpdateAudioProcessingState( + voiceProcessingEnabled: state.voiceProcessingEnabled, + voiceProcessingBypassed: state.voiceProcessingBypassed, + voiceProcessingAGCEnabled: state.voiceProcessingAGCEnabled, + stereoPlayoutEnabled: state.stereoPlayoutEnabled + ) + ) + isVoiceProcessingEnabledSubject.send(state.voiceProcessingEnabled) + isVoiceProcessingBypassedSubject.send(state.voiceProcessingBypassed) + isVoiceProcessingAGCEnabledSubject.send(state.voiceProcessingAGCEnabled) + isStereoPlayoutEnabledSubject.send(state.stereoPlayoutEnabled) + } + + /// Mirrors the subset of properties that can be encoded for debugging. + private enum CodingKeys: String, CodingKey { + case isPlaying + case isRecording + case isMicrophoneMuted + case isStereoPlayoutEnabled + case isVoiceProcessingBypassed + case isVoiceProcessingEnabled + case isVoiceProcessingAGCEnabled + + case audioLevel + } + + /// Serializes the module state, primarily for diagnostic payloads. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(isPlaying, forKey: .isPlaying) + try container.encode(isRecording, forKey: .isRecording) + try container.encode(isMicrophoneMuted, forKey: .isMicrophoneMuted) + try container.encode(isStereoPlayoutEnabled, forKey: .isStereoPlayoutEnabled) + try container.encode(isVoiceProcessingBypassed, forKey: .isVoiceProcessingBypassed) + try container.encode(isVoiceProcessingEnabled, forKey: .isVoiceProcessingEnabled) + try container.encode(isVoiceProcessingAGCEnabled, forKey: .isVoiceProcessingAGCEnabled) + try container.encode(audioLevel, forKey: .audioLevel) + } + + // MARK: - Private helpers + + /// Runs a WebRTC ADM call and translates its integer result into an + /// `AudioDeviceError` enriched with call-site metadata. + private func throwingExecution( + _ message: @autoclosure () -> String, + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line, + _ operation: () -> Int + ) throws { + let result = operation() + + guard result != Constant.successResult else { + return + } + + throw AudioDeviceError.operationFailed(message(), result) + } +} diff --git a/ios/RCTWebRTC/Utils/AudioDeviceModule/AudioEngineLevelNodeAdapter.swift b/ios/RCTWebRTC/Utils/AudioDeviceModule/AudioEngineLevelNodeAdapter.swift new file mode 100644 index 000000000..7251c2d43 --- /dev/null +++ b/ios/RCTWebRTC/Utils/AudioDeviceModule/AudioEngineLevelNodeAdapter.swift @@ -0,0 +1,122 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Accelerate +import AVFoundation +import Combine +import Foundation + +protocol AudioEngineNodeAdapting { + + var subject: CurrentValueSubject? { get set } + + func installInputTap( + on node: AVAudioNode, + format: AVAudioFormat, + bus: Int, + bufferSize: UInt32 + ) + + func uninstall(on bus: Int) +} + +/// Observes an `AVAudioMixerNode` and publishes decibel readings for UI and +/// analytics consumers. +final class AudioEngineLevelNodeAdapter: AudioEngineNodeAdapting { + + enum Constant { + // The down limit of audio pipeline in DB that is considered silence. + static let silenceDB: Float = -160 + } + + var subject: CurrentValueSubject? + + private var inputTap: AVAudioMixerNode? + + /// Installs a tap on the supplied audio node to monitor input levels. + /// - Parameters: + /// - node: The node to observe; must be an `AVAudioMixerNode`. + /// - format: Audio format expected by the tap. + /// - bus: Output bus to observe. + /// - bufferSize: Tap buffer size. + func installInputTap( + on node: AVAudioNode, + format: AVAudioFormat, + bus: Int = 0, + bufferSize: UInt32 = 1024 + ) { + guard let mixer = node as? AVAudioMixerNode, inputTap == nil else { return } + + mixer.installTap( + onBus: bus, + bufferSize: bufferSize, + format: format + ) { [weak self] buffer, _ in + self?.processInputBuffer(buffer) + } + + inputTap = mixer + // log.debug("Input node installed", subsystems: .audioRecording) + } + + /// Removes the tap and resets observed audio levels. + /// - Parameter bus: Bus to remove the tap from, defaults to `0`. + func uninstall(on bus: Int = 0) { + if let mixer = inputTap, mixer.engine != nil { + mixer.removeTap(onBus: 0) + } + subject?.send(Constant.silenceDB) + inputTap = nil + // log.debug("Input node uninstalled", subsystems: .audioRecording) + } + + // MARK: - Private Helpers + + /// Processes the PCM buffer produced by the tap and computes a clamped RMS + /// value which is forwarded to the publisher. + private func processInputBuffer(_ buffer: AVAudioPCMBuffer) { + // Safely unwrap the `subject` (used to publish updates) and the + // `floatChannelData` (pointer to the interleaved or non-interleaved + // channel samples in memory). If either is missing, exit early since + // processing cannot continue. + guard + let subject, + let channelData = buffer.floatChannelData + else { return } + + // Obtain the total number of frames in the buffer as a vDSP-compatible + // length type (`vDSP_Length`). This represents how many samples exist + // per channel in the current audio buffer. + let frameCount = vDSP_Length(buffer.frameLength) + + // Declare a variable to store the computed RMS (root-mean-square) + // amplitude value for the buffer. It will represent the signal's + // average power in linear scale (not decibels yet). + var rms: Float = 0 + + // Use Apple's Accelerate framework to efficiently compute the RMS + // (root mean square) of the float samples in the first channel. + // - Parameters: + // - channelData[0]: Pointer to the first channel’s samples. + // - 1: Stride between consecutive elements (every sample). + // - &rms: Output variable to store the computed RMS. + // - frameCount: Number of samples to process. + vDSP_rmsqv(channelData[0], 1, &rms, frameCount) + + // Convert the linear RMS value to decibels using the formula + // 20 * log10(rms). To avoid a log of zero (which is undefined), + // use `max(rms, Float.ulpOfOne)` to ensure a minimal positive value. + let rmsDB = 20 * log10(max(rms, Float.ulpOfOne)) + + // Clamp the computed decibel value to a reasonable audio level range + // between -160 dB (silence) and 0 dB (maximum). This prevents extreme + // or invalid values that may occur due to noise or computation errors. + let clampedRMS = max(-160.0, min(0.0, Float(rmsDB))) + + // Publish the clamped decibel value to the CurrentValueSubject so that + // subscribers (e.g., UI level meters or analytics systems) receive the + // updated level reading. + subject.send(clampedRMS) + } +} diff --git a/ios/RCTWebRTC/Utils/AudioDeviceModule/RTCAudioDeviceModuleControlling.swift b/ios/RCTWebRTC/Utils/AudioDeviceModule/RTCAudioDeviceModuleControlling.swift new file mode 100644 index 000000000..fefd1671b --- /dev/null +++ b/ios/RCTWebRTC/Utils/AudioDeviceModule/RTCAudioDeviceModuleControlling.swift @@ -0,0 +1,47 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import WebRTC + +/// Abstraction over `RTCAudioDeviceModule` so tests can provide fakes while +/// production code continues to rely on the WebRTC-backed implementation. +protocol RTCAudioDeviceModuleControlling: AnyObject { + var observer: RTCAudioDeviceModuleDelegate? { get set } + var isPlaying: Bool { get } + var isRecording: Bool { get } + var isPlayoutInitialized: Bool { get } + var isRecordingInitialized: Bool { get } + var isMicrophoneMuted: Bool { get } + var isStereoPlayoutEnabled: Bool { get } + var isVoiceProcessingBypassed: Bool { get set } + var isVoiceProcessingEnabled: Bool { get } + var isVoiceProcessingAGCEnabled: Bool { get } + var prefersStereoPlayout: Bool { get set } + + func reset() -> Int + func initAndStartPlayout() -> Int + func startPlayout() -> Int + func stopPlayout() -> Int + func initAndStartRecording() -> Int + func setMicrophoneMuted(_ isMuted: Bool) -> Int + func startRecording() -> Int + func stopRecording() -> Int + func refreshStereoPlayoutState() + func setMuteMode(_ mode: RTCAudioEngineMuteMode) -> Int + func setRecordingAlwaysPreparedMode(_ alwaysPreparedRecording: Bool) -> Int +} + +extension RTCAudioDeviceModule: RTCAudioDeviceModuleControlling { + /// Convenience wrapper that mirrors the old `initPlayout` and + /// `startPlayout` sequence so the caller can request playout in one call. + func initAndStartPlayout() -> Int { + let result = initPlayout() + if result == 0 { + return startPlayout() + } else { + return result + } + } +} diff --git a/ios/RCTWebRTC/WebRTCModule.h b/ios/RCTWebRTC/WebRTCModule.h index 3ff079e0e..5f20e3fb7 100644 --- a/ios/RCTWebRTC/WebRTCModule.h +++ b/ios/RCTWebRTC/WebRTCModule.h @@ -23,6 +23,8 @@ static NSString *const kEventMediaStreamTrackEnded = @"mediaStreamTrackEnded"; static NSString *const kEventPeerConnectionOnRemoveTrack = @"peerConnectionOnRemoveTrack"; static NSString *const kEventPeerConnectionOnTrack = @"peerConnectionOnTrack"; +@class AudioDeviceModule; + @interface WebRTCModule : RCTEventEmitter @property(nonatomic, strong) dispatch_queue_t workerQueue; @@ -30,6 +32,7 @@ static NSString *const kEventPeerConnectionOnTrack = @"peerConnectionOnTrack"; @property(nonatomic, strong) RTCPeerConnectionFactory *peerConnectionFactory; @property(nonatomic, strong) id decoderFactory; @property(nonatomic, strong) id encoderFactory; +@property(nonatomic, strong) AudioDeviceModule *audioDeviceModule; @property(nonatomic, strong) NSMutableDictionary *peerConnections; @property(nonatomic, strong) NSMutableDictionary *localStreams; diff --git a/ios/RCTWebRTC/WebRTCModule.m b/ios/RCTWebRTC/WebRTCModule.m index bef9e5c17..48e53ac5a 100644 --- a/ios/RCTWebRTC/WebRTCModule.m +++ b/ios/RCTWebRTC/WebRTCModule.m @@ -11,6 +11,9 @@ #import "WebRTCModule.h" #import "WebRTCModuleOptions.h" +// Import Swift classes +#import + @interface WebRTCModule () @end @@ -78,7 +81,7 @@ - (instancetype)init { } RCTLogInfo(@"Using audio processing module: %@", NSStringFromClass([audioProcessingModule class])); _peerConnectionFactory = - [[RTCPeerConnectionFactory alloc] initWithAudioDeviceModuleType:RTCAudioDeviceModuleTypePlatformDefault + [[RTCPeerConnectionFactory alloc] initWithAudioDeviceModuleType:RTCAudioDeviceModuleTypeAudioEngine bypassVoiceProcessing:NO encoderFactory:encoderFactory decoderFactory:decoderFactory @@ -90,13 +93,15 @@ - (instancetype)init { audioDevice:audioDevice]; } else { _peerConnectionFactory = - [[RTCPeerConnectionFactory alloc] initWithAudioDeviceModuleType:RTCAudioDeviceModuleTypePlatformDefault + [[RTCPeerConnectionFactory alloc] initWithAudioDeviceModuleType:RTCAudioDeviceModuleTypeAudioEngine bypassVoiceProcessing:NO encoderFactory:encoderFactory decoderFactory:decoderFactory audioProcessingModule:nil]; } + _audioDeviceModule = [[AudioDeviceModule alloc] initWithSource:_peerConnectionFactory.audioDeviceModule]; + _peerConnections = [NSMutableDictionary new]; _localStreams = [NSMutableDictionary new]; _localTracks = [NSMutableDictionary new]; diff --git a/package-lock.json b/package-lock.json index 280a4303f..76c7f6db9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@stream-io/react-native-webrtc", - "version": "137.0.2", + "version": "137.1.0-alpha.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@stream-io/react-native-webrtc", - "version": "137.0.2", + "version": "137.1.0-alpha.1", "license": "MIT", "dependencies": { "base64-js": "1.5.1", diff --git a/package.json b/package.json index 52b6f0aa2..ae64971ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/react-native-webrtc", - "version": "137.0.2", + "version": "137.1.0-alpha.1", "repository": { "type": "git", "url": "git+https://github.com/GetStream/react-native-webrtc.git"