Skip to content

Commit 1e82d99

Browse files
feat: stereo audio output support (#23)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added comprehensive audio device management with hardware-based echo cancellation and noise suppression. * Introduced audio state monitoring and control capabilities. * Added audio level monitoring functionality. * **Bug Fixes** * Adjusted audio session configuration for improved compatibility. * **Refactor** * Restructured audio handling to use modern audio engine architecture. * Enhanced audio device initialization process. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 687b266 commit 1e82d99

File tree

11 files changed

+776
-13
lines changed

11 files changed

+776
-13
lines changed

android/build.gradle

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ repositories {
3636
google()
3737
}
3838

39-
40-
4139
def safeExtGet(prop, fallback) {
4240
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
4341
}

android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.oney.WebRTCModule;
22

3+
import android.os.Build;
34
import android.util.Log;
45
import android.util.Pair;
56
import android.util.SparseArray;
@@ -82,11 +83,10 @@ public WebRTCModule(ReactApplicationContext reactContext) {
8283
EglBase.Context eglContext = EglUtils.getRootEglBaseContext();
8384
encoderFactory = new SimulcastAlignedVideoEncoderFactory(eglContext, true, true, ResolutionAdjustment.MULTIPLE_OF_16);
8485
decoderFactory = new SelectiveVideoDecoderFactory(eglContext, false, Arrays.asList("VP9", "AV1"));
85-
8686
}
8787

8888
if (adm == null) {
89-
adm = JavaAudioDeviceModule.builder(reactContext).createAudioDeviceModule();
89+
adm = createAudioDeviceModule(reactContext);
9090
}
9191

9292
AudioProcessingFactory audioProcessingFactory = null;
@@ -123,6 +123,15 @@ public WebRTCModule(ReactApplicationContext reactContext) {
123123
getUserMediaImpl = new GetUserMediaImpl(this, reactContext);
124124
}
125125

126+
private JavaAudioDeviceModule createAudioDeviceModule(ReactApplicationContext reactContext) {
127+
return JavaAudioDeviceModule
128+
.builder(reactContext)
129+
.setUseHardwareAcousticEchoCanceler(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
130+
.setUseHardwareNoiseSuppressor(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
131+
.setUseStereoOutput(true)
132+
.createAudioDeviceModule();
133+
}
134+
126135
@NonNull
127136
@Override
128137
public String getName() {

ios/RCTWebRTC/Utils/AudioDeviceModule/AudioDeviceModule.swift

Lines changed: 573 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Accelerate
6+
import AVFoundation
7+
import Combine
8+
import Foundation
9+
10+
protocol AudioEngineNodeAdapting {
11+
12+
var subject: CurrentValueSubject<Float, Never>? { get set }
13+
14+
func installInputTap(
15+
on node: AVAudioNode,
16+
format: AVAudioFormat,
17+
bus: Int,
18+
bufferSize: UInt32
19+
)
20+
21+
func uninstall(on bus: Int)
22+
}
23+
24+
/// Observes an `AVAudioMixerNode` and publishes decibel readings for UI and
25+
/// analytics consumers.
26+
final class AudioEngineLevelNodeAdapter: AudioEngineNodeAdapting {
27+
28+
enum Constant {
29+
// The down limit of audio pipeline in DB that is considered silence.
30+
static let silenceDB: Float = -160
31+
}
32+
33+
var subject: CurrentValueSubject<Float, Never>?
34+
35+
private var inputTap: AVAudioMixerNode?
36+
37+
/// Installs a tap on the supplied audio node to monitor input levels.
38+
/// - Parameters:
39+
/// - node: The node to observe; must be an `AVAudioMixerNode`.
40+
/// - format: Audio format expected by the tap.
41+
/// - bus: Output bus to observe.
42+
/// - bufferSize: Tap buffer size.
43+
func installInputTap(
44+
on node: AVAudioNode,
45+
format: AVAudioFormat,
46+
bus: Int = 0,
47+
bufferSize: UInt32 = 1024
48+
) {
49+
guard let mixer = node as? AVAudioMixerNode, inputTap == nil else { return }
50+
51+
mixer.installTap(
52+
onBus: bus,
53+
bufferSize: bufferSize,
54+
format: format
55+
) { [weak self] buffer, _ in
56+
self?.processInputBuffer(buffer)
57+
}
58+
59+
inputTap = mixer
60+
// log.debug("Input node installed", subsystems: .audioRecording)
61+
}
62+
63+
/// Removes the tap and resets observed audio levels.
64+
/// - Parameter bus: Bus to remove the tap from, defaults to `0`.
65+
func uninstall(on bus: Int = 0) {
66+
if let mixer = inputTap, mixer.engine != nil {
67+
mixer.removeTap(onBus: 0)
68+
}
69+
subject?.send(Constant.silenceDB)
70+
inputTap = nil
71+
// log.debug("Input node uninstalled", subsystems: .audioRecording)
72+
}
73+
74+
// MARK: - Private Helpers
75+
76+
/// Processes the PCM buffer produced by the tap and computes a clamped RMS
77+
/// value which is forwarded to the publisher.
78+
private func processInputBuffer(_ buffer: AVAudioPCMBuffer) {
79+
// Safely unwrap the `subject` (used to publish updates) and the
80+
// `floatChannelData` (pointer to the interleaved or non-interleaved
81+
// channel samples in memory). If either is missing, exit early since
82+
// processing cannot continue.
83+
guard
84+
let subject,
85+
let channelData = buffer.floatChannelData
86+
else { return }
87+
88+
// Obtain the total number of frames in the buffer as a vDSP-compatible
89+
// length type (`vDSP_Length`). This represents how many samples exist
90+
// per channel in the current audio buffer.
91+
let frameCount = vDSP_Length(buffer.frameLength)
92+
93+
// Declare a variable to store the computed RMS (root-mean-square)
94+
// amplitude value for the buffer. It will represent the signal's
95+
// average power in linear scale (not decibels yet).
96+
var rms: Float = 0
97+
98+
// Use Apple's Accelerate framework to efficiently compute the RMS
99+
// (root mean square) of the float samples in the first channel.
100+
// - Parameters:
101+
// - channelData[0]: Pointer to the first channel’s samples.
102+
// - 1: Stride between consecutive elements (every sample).
103+
// - &rms: Output variable to store the computed RMS.
104+
// - frameCount: Number of samples to process.
105+
vDSP_rmsqv(channelData[0], 1, &rms, frameCount)
106+
107+
// Convert the linear RMS value to decibels using the formula
108+
// 20 * log10(rms). To avoid a log of zero (which is undefined),
109+
// use `max(rms, Float.ulpOfOne)` to ensure a minimal positive value.
110+
let rmsDB = 20 * log10(max(rms, Float.ulpOfOne))
111+
112+
// Clamp the computed decibel value to a reasonable audio level range
113+
// between -160 dB (silence) and 0 dB (maximum). This prevents extreme
114+
// or invalid values that may occur due to noise or computation errors.
115+
let clampedRMS = max(-160.0, min(0.0, Float(rmsDB)))
116+
117+
// Publish the clamped decibel value to the CurrentValueSubject so that
118+
// subscribers (e.g., UI level meters or analytics systems) receive the
119+
// updated level reading.
120+
subject.send(clampedRMS)
121+
}
122+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Combine
6+
import WebRTC
7+
8+
/// Abstraction over `RTCAudioDeviceModule` so tests can provide fakes while
9+
/// production code continues to rely on the WebRTC-backed implementation.
10+
protocol RTCAudioDeviceModuleControlling: AnyObject {
11+
var observer: RTCAudioDeviceModuleDelegate? { get set }
12+
var isPlaying: Bool { get }
13+
var isRecording: Bool { get }
14+
var isPlayoutInitialized: Bool { get }
15+
var isRecordingInitialized: Bool { get }
16+
var isMicrophoneMuted: Bool { get }
17+
var isStereoPlayoutEnabled: Bool { get }
18+
var isVoiceProcessingBypassed: Bool { get set }
19+
var isVoiceProcessingEnabled: Bool { get }
20+
var isVoiceProcessingAGCEnabled: Bool { get }
21+
var prefersStereoPlayout: Bool { get set }
22+
23+
func reset() -> Int
24+
func initAndStartPlayout() -> Int
25+
func startPlayout() -> Int
26+
func stopPlayout() -> Int
27+
func initAndStartRecording() -> Int
28+
func setMicrophoneMuted(_ isMuted: Bool) -> Int
29+
func startRecording() -> Int
30+
func stopRecording() -> Int
31+
func refreshStereoPlayoutState()
32+
func setMuteMode(_ mode: RTCAudioEngineMuteMode) -> Int
33+
func setRecordingAlwaysPreparedMode(_ alwaysPreparedRecording: Bool) -> Int
34+
}
35+
36+
extension RTCAudioDeviceModule: RTCAudioDeviceModuleControlling {
37+
/// Convenience wrapper that mirrors the old `initPlayout` and
38+
/// `startPlayout` sequence so the caller can request playout in one call.
39+
func initAndStartPlayout() -> Int {
40+
let result = initPlayout()
41+
if result == 0 {
42+
return startPlayout()
43+
} else {
44+
return result
45+
}
46+
}
47+
}

ios/RCTWebRTC/WebRTCModule+RTCMediaStream.m

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -636,9 +636,7 @@ - (void)ensureAudioSessionWithRecording {
636636
[session lockForConfiguration];
637637
config.category = AVAudioSessionCategoryPlayAndRecord;
638638
config.categoryOptions =
639-
AVAudioSessionCategoryOptionAllowAirPlay|
640639
AVAudioSessionCategoryOptionAllowBluetooth|
641-
AVAudioSessionCategoryOptionAllowBluetoothA2DP|
642640
AVAudioSessionCategoryOptionDefaultToSpeaker;
643641
config.mode = AVAudioSessionModeVideoChat;
644642
NSError* error = nil;

ios/RCTWebRTC/WebRTCModule.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@ static NSString *const kEventMediaStreamTrackEnded = @"mediaStreamTrackEnded";
2323
static NSString *const kEventPeerConnectionOnRemoveTrack = @"peerConnectionOnRemoveTrack";
2424
static NSString *const kEventPeerConnectionOnTrack = @"peerConnectionOnTrack";
2525

26+
@class AudioDeviceModule;
27+
2628
@interface WebRTCModule : RCTEventEmitter<RCTBridgeModule>
2729

2830
@property(nonatomic, strong) dispatch_queue_t workerQueue;
2931

3032
@property(nonatomic, strong) RTCPeerConnectionFactory *peerConnectionFactory;
3133
@property(nonatomic, strong) id<RTCVideoDecoderFactory> decoderFactory;
3234
@property(nonatomic, strong) id<RTCVideoEncoderFactory> encoderFactory;
35+
@property(nonatomic, strong) AudioDeviceModule *audioDeviceModule;
3336

3437
@property(nonatomic, strong) NSMutableDictionary<NSNumber *, RTCPeerConnection *> *peerConnections;
3538
@property(nonatomic, strong) NSMutableDictionary<NSString *, RTCMediaStream *> *localStreams;

ios/RCTWebRTC/WebRTCModule.m

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@
1111
#import "WebRTCModule.h"
1212
#import "WebRTCModuleOptions.h"
1313

14+
// Import Swift classes
15+
// We need the following if and elif directives to properly import the generated Swift header for the module,
16+
// handling both cases where CocoaPods module import path is available and where it is not.
17+
// This ensures compatibility regardless of whether the project is built with frameworks enabled or as static libraries.
18+
#if __has_include(<stream_react_native_webrtc/stream_react_native_webrtc-Swift.h>)
19+
#import <stream_react_native_webrtc/stream_react_native_webrtc-Swift.h>
20+
#elif __has_include("stream_react_native_webrtc-Swift.h")
21+
#import "stream_react_native_webrtc-Swift.h"
22+
#endif
23+
1424
@interface WebRTCModule ()
1525
@end
1626

@@ -78,7 +88,7 @@ - (instancetype)init {
7888
}
7989
RCTLogInfo(@"Using audio processing module: %@", NSStringFromClass([audioProcessingModule class]));
8090
_peerConnectionFactory =
81-
[[RTCPeerConnectionFactory alloc] initWithAudioDeviceModuleType:RTCAudioDeviceModuleTypePlatformDefault
91+
[[RTCPeerConnectionFactory alloc] initWithAudioDeviceModuleType:RTCAudioDeviceModuleTypeAudioEngine
8292
bypassVoiceProcessing:NO
8393
encoderFactory:encoderFactory
8494
decoderFactory:decoderFactory
@@ -90,13 +100,15 @@ - (instancetype)init {
90100
audioDevice:audioDevice];
91101
} else {
92102
_peerConnectionFactory =
93-
[[RTCPeerConnectionFactory alloc] initWithAudioDeviceModuleType:RTCAudioDeviceModuleTypePlatformDefault
103+
[[RTCPeerConnectionFactory alloc] initWithAudioDeviceModuleType:RTCAudioDeviceModuleTypeAudioEngine
94104
bypassVoiceProcessing:NO
95105
encoderFactory:encoderFactory
96106
decoderFactory:decoderFactory
97107
audioProcessingModule:nil];
98108
}
99109

110+
_audioDeviceModule = [[AudioDeviceModule alloc] initWithSource:_peerConnectionFactory.audioDeviceModule];
111+
100112
_peerConnections = [NSMutableDictionary new];
101113
_localStreams = [NSMutableDictionary new];
102114
_localTracks = [NSMutableDictionary new];

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stream-io/react-native-webrtc",
3-
"version": "137.0.2",
3+
"version": "137.1.0-alpha.2",
44
"repository": {
55
"type": "git",
66
"url": "git+https://github.com/GetStream/react-native-webrtc.git"

0 commit comments

Comments
 (0)