diff --git a/.gitignore b/.gitignore
index ba4d9bdf9..743ca6449 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,8 @@ WebRTC.xcframework
WebRTC.dSYMs
examples/GumTestApp/package-lock.json
examples/GumTestApp_macOS/package-lock.json
+**/.xcode.env.local
+**/PLAN.md
*.jar
*.tgz
*.zip
diff --git a/android/src/main/java/com/oney/WebRTCModule/SpeechActivityDetector.java b/android/src/main/java/com/oney/WebRTCModule/SpeechActivityDetector.java
new file mode 100644
index 000000000..c621440dc
--- /dev/null
+++ b/android/src/main/java/com/oney/WebRTCModule/SpeechActivityDetector.java
@@ -0,0 +1,153 @@
+package com.oney.WebRTCModule;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.ShortBuffer;
+
+/**
+ * Tells you when the user is talking, by watching how loud the mic is over time.
+ *
+ *
How it works:
+ *
+ * - Every ~10 ms the mic gives us a chunk of samples.
+ * - Convert each chunk to one "loudness" number in dBFS (decibels relative
+ * to full scale): quiet room ≈ -60 dB, normal speech ≈ -30 to -20 dB,
+ * speaking close to the mic ≈ -15 to -10 dB.
+ * - Track two things only: when we last saw a loud chunk and
+ * when the current run of loud chunks started.
+ * - Fire {@code onSpeechStarted} once we've had loud chunks for
+ * {@link #START_CONFIRM_MS} in a row. Fire {@code onSpeechEnded} once
+ * {@link #SILENCE_TIMEOUT_MS} has passed with no loud chunks. The
+ * timeout is long enough to span natural between-word pauses.
+ *
+ *
+ * Why this, not a rolling dB average? Android's AGC (automatic gain
+ * control) ramps the mic gain back up the instant speech stops, amplifying
+ * room noise to -35 or -40 dB. A rolling average over that noise never drops
+ * below the threshold, so {@code onSpeechEnded} would never fire. Looking at
+ * "time since last loud peak" is immune to that — pauses between words are
+ * short, but a real stop is sustained.
+ *
+ *
Alignment with stream-video-android. stream-video-android's
+ * {@code SoundInputProcessor} fires only an "edge-up" callback and relies on
+ * the app layer to infer "stopped". We need the {@code ended} edge to match
+ * the iOS contract, so we add the silence-timeout inference here using the
+ * same {@code -45 dBFS} threshold they use.
+ *
+ *
Not "real" voice recognition. This only looks at energy/loudness,
+ * not voice features. Loud non-voice sounds (typing, door slams, music) will
+ * trigger {@code onSpeechStarted}. iOS uses Apple's hardware VAD which is
+ * smarter, but Android has no equivalent — same tradeoff stream-video-android
+ * lives with.
+ *
+ *
Thread-safety: single-threaded — only the WebRTC audio thread should call
+ * {@link #processBuffer}. Listener callbacks fire synchronously on that thread;
+ * the listener is responsible for dispatching to the JS thread.
+ */
+class SpeechActivityDetector {
+
+ interface Listener {
+ void onSpeechStarted();
+ void onSpeechEnded();
+ }
+
+ /** Above this dBFS level a chunk counts as "loud". Matches stream-video-android. */
+ private static final double THRESHOLD_DB = -45.0;
+ /** Require loud chunks for this long before firing started (rejects door slams). */
+ private static final long START_CONFIRM_MS = 150;
+ /** Fire ended after this long with no loud chunk (spans natural between-word pauses). */
+ private static final long SILENCE_TIMEOUT_MS = 900;
+
+ private final Listener listener;
+
+ private boolean isSpeaking = false;
+ /** Start of the current run of above-threshold chunks, or -1 if last chunk was quiet. */
+ private long firstLoudMs = -1;
+ /** Last time any chunk was above threshold, or -1 if never (or cleared on ended). */
+ private long lastLoudMs = -1;
+
+ SpeechActivityDetector(Listener listener) {
+ this.listener = listener;
+ }
+
+ /**
+ * Feed one mic chunk through the detector. Reads PCM16 LE samples from
+ * {@code audioBuffer} without mutating its position/limit. May fire a
+ * listener callback synchronously if state flips.
+ *
+ *
Must be called on the WebRTC audio thread, BEFORE any code that mutates
+ * {@code audioBuffer} (e.g. screen-audio mixing) — otherwise the detector
+ * sees post-mix audio and triggers on system sounds.
+ */
+ void processBuffer(ByteBuffer audioBuffer, int bytesRead) {
+ if (bytesRead <= 0) {
+ return;
+ }
+
+ // Work on a duplicate so we never mutate the caller's position/limit.
+ ByteBuffer buf = audioBuffer.duplicate();
+ buf.position(0);
+ buf.limit(bytesRead);
+ buf.order(ByteOrder.LITTLE_ENDIAN);
+ ShortBuffer shorts = buf.asShortBuffer();
+
+ int numSamples = shorts.remaining();
+ if (numSamples == 0) {
+ return;
+ }
+
+ // Normalize int16 samples to [-1.0, 1.0] BEFORE squaring so the resulting
+ // dB value is dBFS (decibels relative to full scale). Without this, dB is
+ // computed against a 1-sample-unit reference and silence reads as ~+40.
+ double sumSquares = 0;
+ for (int i = 0; i < numSamples; i++) {
+ double sample = shorts.get(i) / (double) Short.MAX_VALUE;
+ sumSquares += sample * sample;
+ }
+
+ double rms = Math.sqrt(sumSquares / numSamples);
+ double db = (rms > 0) ? 20.0 * Math.log10(rms) : -100.0;
+
+ long now = System.currentTimeMillis();
+
+ if (db > THRESHOLD_DB) {
+ // Loud chunk. Open a start window if one isn't already open, and
+ // remember this as the most recent loud chunk for ended timing.
+ lastLoudMs = now;
+ if (firstLoudMs < 0) {
+ firstLoudMs = now;
+ }
+ if (!isSpeaking && now - firstLoudMs >= START_CONFIRM_MS) {
+ isSpeaking = true;
+ listener.onSpeechStarted();
+ }
+ } else {
+ // Quiet chunk. Cancel any in-progress start confirmation. If we're
+ // already speaking, fire ended once the silence is long enough.
+ firstLoudMs = -1;
+ if (isSpeaking && lastLoudMs > 0 && now - lastLoudMs >= SILENCE_TIMEOUT_MS) {
+ isSpeaking = false;
+ lastLoudMs = -1;
+ listener.onSpeechEnded();
+ }
+ }
+ }
+
+ /** Wipes state. Call on recorder start. No event fires. */
+ void reset() {
+ isSpeaking = false;
+ firstLoudMs = -1;
+ lastLoudMs = -1;
+ }
+
+ /**
+ * Call on recorder stop. If we were in {@code started}, force-fires
+ * {@code onSpeechEnded} so JS doesn't get latched, then resets.
+ */
+ void onRecordStop() {
+ if (isSpeaking) {
+ listener.onSpeechEnded();
+ }
+ reset();
+ }
+}
diff --git a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java
index 831232cef..089f6150a 100644
--- a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java
+++ b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java
@@ -51,6 +51,7 @@ public class WebRTCModule extends ReactContextBaseJavaModule {
final Map localStreams;
private final GetUserMediaImpl getUserMediaImpl;
+ private SpeechActivityDetector speechActivityDetector;
public WebRTCModule(ReactApplicationContext reactContext) {
super(reactContext);
@@ -124,12 +125,32 @@ public WebRTCModule(ReactApplicationContext reactContext) {
}
private JavaAudioDeviceModule createAudioDeviceModule(ReactApplicationContext reactContext) {
+ speechActivityDetector = new SpeechActivityDetector(new SpeechActivityDetector.Listener() {
+ @Override
+ public void onSpeechStarted() {
+ WritableMap params = Arguments.createMap();
+ params.putString("event", "started");
+ sendEvent("audioDeviceModuleSpeechActivity", params);
+ }
+
+ @Override
+ public void onSpeechEnded() {
+ WritableMap params = Arguments.createMap();
+ params.putString("event", "ended");
+ sendEvent("audioDeviceModuleSpeechActivity", params);
+ }
+ });
+
return JavaAudioDeviceModule
.builder(reactContext)
.setUseHardwareAcousticEchoCanceler(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
.setUseHardwareNoiseSuppressor(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
.setUseStereoOutput(true)
.setAudioBufferCallback((audioBuffer, audioFormat, channelCount, sampleRate, bytesRead, captureTimeNs) -> {
+ // 1. Speech activity detection on raw mic data, BEFORE any mutation.
+ speechActivityDetector.processBuffer(audioBuffer, bytesRead);
+
+ // 2. Existing screen-audio mixing — mutates audioBuffer in place.
if (bytesRead > 0) {
WebRTCModuleOptions.ScreenAudioBytesProvider provider =
WebRTCModuleOptions.getInstance().screenAudioBytesProvider;
@@ -142,6 +163,17 @@ private JavaAudioDeviceModule createAudioDeviceModule(ReactApplicationContext re
}
return captureTimeNs;
})
+ .setAudioRecordStateCallback(new JavaAudioDeviceModule.AudioRecordStateCallback() {
+ @Override
+ public void onWebRtcAudioRecordStart() {
+ speechActivityDetector.reset();
+ }
+
+ @Override
+ public void onWebRtcAudioRecordStop() {
+ speechActivityDetector.onRecordStop();
+ }
+ })
.createAudioDeviceModule();
}
diff --git a/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj b/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj
index d04f17a7c..e5ba6d775 100644
--- a/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj
+++ b/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj
@@ -759,7 +759,9 @@
);
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
- OTHER_LDFLAGS = "$(inherited) ";
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ );
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -816,7 +818,9 @@
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = NO;
- OTHER_LDFLAGS = "$(inherited) ";
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ );
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
diff --git a/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.h b/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.h
new file mode 100644
index 000000000..32fcd47f5
--- /dev/null
+++ b/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.h
@@ -0,0 +1,5 @@
+#import "WebRTCModule.h"
+
+@interface WebRTCModule (RTCAudioDeviceModule)
+
+@end
diff --git a/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m b/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m
new file mode 100644
index 000000000..b531781c7
--- /dev/null
+++ b/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m
@@ -0,0 +1,177 @@
+#import
+
+#import
+#import
+
+#import "WebRTCModule.h"
+
+// The underlying `RTCAudioDeviceModule` is owned by the `RTCPeerConnectionFactory`.
+// `WebRTCModule.audioDeviceModule` is a Swift wrapper around it, so we reach for the
+// raw device module here when we need to call APIs that are only defined on
+// `RTCAudioDeviceModule`.
+#define RAW_ADM (self.peerConnectionFactory.audioDeviceModule)
+
+@implementation WebRTCModule (RTCAudioDeviceModule)
+
+- (void)handleADMResult:(NSInteger)result
+ operation:(NSString *)op
+ code:(NSString *)code
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ if (result == 0) {
+ resolve(nil);
+ } else {
+ reject(code, [NSString stringWithFormat:@"Failed to %@: %ld", op, (long)result], nil);
+ }
+}
+
+#pragma mark - Recording & Playback Control
+
+RCT_EXPORT_METHOD(audioDeviceModuleStartPlayout
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject) {
+ [self handleADMResult:[RAW_ADM startPlayout] operation:@"start playout" code:@"playout_error" resolve:resolve reject:reject];
+}
+
+RCT_EXPORT_METHOD(audioDeviceModuleStopPlayout
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject) {
+ [self handleADMResult:[RAW_ADM stopPlayout] operation:@"stop playout" code:@"playout_error" resolve:resolve reject:reject];
+}
+
+RCT_EXPORT_METHOD(audioDeviceModuleStartRecording
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject) {
+ [self handleADMResult:[RAW_ADM startRecording] operation:@"start recording" code:@"recording_error" resolve:resolve reject:reject];
+}
+
+RCT_EXPORT_METHOD(audioDeviceModuleStopRecording
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject) {
+ [self handleADMResult:[RAW_ADM stopRecording] operation:@"stop recording" code:@"recording_error" resolve:resolve reject:reject];
+}
+
+RCT_EXPORT_METHOD(audioDeviceModuleStartLocalRecording
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject) {
+ [self handleADMResult:[RAW_ADM initAndStartRecording] operation:@"start local recording" code:@"recording_error" resolve:resolve reject:reject];
+}
+
+RCT_EXPORT_METHOD(audioDeviceModuleStopLocalRecording
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject) {
+ [self handleADMResult:[RAW_ADM stopRecording] operation:@"stop local recording" code:@"recording_error" resolve:resolve reject:reject];
+}
+
+#pragma mark - Microphone Control
+
+RCT_EXPORT_METHOD(audioDeviceModuleSetMicrophoneMuted
+ : (BOOL)muted resolver
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject) {
+ [self handleADMResult:[RAW_ADM setMicrophoneMuted:muted] operation:@"set microphone mute" code:@"mute_error" resolve:resolve reject:reject];
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleIsMicrophoneMuted) {
+ return @(RAW_ADM.isMicrophoneMuted);
+}
+
+#pragma mark - Voice Processing
+
+RCT_EXPORT_METHOD(audioDeviceModuleSetVoiceProcessingEnabled
+ : (BOOL)enabled resolver
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject) {
+ [self handleADMResult:[RAW_ADM setVoiceProcessingEnabled:enabled] operation:@"set voice processing" code:@"voice_processing_error" resolve:resolve reject:reject];
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleIsVoiceProcessingEnabled) {
+ return @(RAW_ADM.isVoiceProcessingEnabled);
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetVoiceProcessingBypassed : (BOOL)bypassed) {
+ RAW_ADM.voiceProcessingBypassed = bypassed;
+ return nil;
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleIsVoiceProcessingBypassed) {
+ return @(RAW_ADM.isVoiceProcessingBypassed);
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetVoiceProcessingAGCEnabled : (BOOL)enabled) {
+ RAW_ADM.voiceProcessingAGCEnabled = enabled;
+ return nil;
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleIsVoiceProcessingAGCEnabled) {
+ return @(RAW_ADM.isVoiceProcessingAGCEnabled);
+}
+
+#pragma mark - Status
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleIsPlaying) {
+ return @(RAW_ADM.isPlaying);
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleIsRecording) {
+ return @(RAW_ADM.isRecording);
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleIsEngineRunning) {
+ return @(RAW_ADM.isEngineRunning);
+}
+
+#pragma mark - Advanced Features
+
+RCT_EXPORT_METHOD(audioDeviceModuleSetMuteMode
+ : (NSInteger)mode resolver
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject) {
+ [self handleADMResult:[RAW_ADM setMuteMode:(RTCAudioEngineMuteMode)mode] operation:@"set mute mode" code:@"mute_mode_error" resolve:resolve reject:reject];
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleGetMuteMode) {
+ return @(RAW_ADM.muteMode);
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetAdvancedDuckingEnabled : (BOOL)enabled) {
+ RAW_ADM.advancedDuckingEnabled = enabled;
+ return nil;
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleIsAdvancedDuckingEnabled) {
+ return @(RAW_ADM.isAdvancedDuckingEnabled);
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetDuckingLevel : (NSInteger)level) {
+ RAW_ADM.duckingLevel = level;
+ return nil;
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleGetDuckingLevel) {
+ return @(RAW_ADM.duckingLevel);
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleIsRecordingAlwaysPreparedMode) {
+ return @(RAW_ADM.recordingAlwaysPreparedMode);
+}
+
+RCT_EXPORT_METHOD(audioDeviceModuleSetRecordingAlwaysPreparedMode
+ : (BOOL)enabled resolver
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject) {
+ [self handleADMResult:[RAW_ADM setRecordingAlwaysPreparedMode:enabled] operation:@"set recording always prepared mode" code:@"recording_always_prepared_mode_error" resolve:resolve reject:reject];
+}
+
+// TODO: `getEngineAvailability` / `setEngineAvailability` were dropped because the
+// Stream WebRTC SDK does not expose `RTCAudioEngineAvailability` / `-setEngineAvailability:`.
+// The closest equivalent is `RTCAudioEngineState` via `engineState`, but the
+// semantics differ and the JS API isn't consumed anywhere yet.
+
+// TODO: Observer delegate "resolve" methods were skipped because our current
+// `AudioDeviceModuleObserver` does not expose async JS-driven resolution hooks;
+// the Swift `AudioDeviceModule` wrapper always returns success immediately.
+
+@end
+
+#undef RAW_ADM
diff --git a/ios/RCTWebRTC/WebRTCModule.h b/ios/RCTWebRTC/WebRTCModule.h
index 538240911..ace2ba043 100644
--- a/ios/RCTWebRTC/WebRTCModule.h
+++ b/ios/RCTWebRTC/WebRTCModule.h
@@ -48,6 +48,8 @@ static NSString *const kEventAudioDeviceModuleAudioProcessingStateUpdated = @"au
@property(nonatomic, strong) NSMutableDictionary *localStreams;
@property(nonatomic, strong) NSMutableDictionary *localTracks;
+// TODO: FrameCryption is not supported by this SDK yet. These containers are
+// retained so the native factory initialization keeps working unchanged.
@property(nonatomic, strong) NSMutableDictionary *frameCryptors;
@property(nonatomic, strong) NSMutableDictionary *keyProviders;
@property(nonatomic, strong) NSMutableDictionary *dataPacketCryptors;
diff --git a/ios/RCTWebRTC/WebRTCModule.m b/ios/RCTWebRTC/WebRTCModule.m
index 4455e60f2..5188a7496 100644
--- a/ios/RCTWebRTC/WebRTCModule.m
+++ b/ios/RCTWebRTC/WebRTCModule.m
@@ -131,6 +131,7 @@ - (instancetype)init {
_localStreams = [NSMutableDictionary new];
_localTracks = [NSMutableDictionary new];
+ // TODO: FrameCryption is not supported yet; dictionaries left empty.
_frameCryptors = [NSMutableDictionary new];
_keyProviders = [NSMutableDictionary new];
_dataPacketCryptors = [NSMutableDictionary new];
diff --git a/package-lock.json b/package-lock.json
index 3129d42d5..8f3a70152 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@stream-io/react-native-webrtc",
- "version": "137.1.3",
+ "version": "137.1.4-alpha.5",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@stream-io/react-native-webrtc",
- "version": "137.1.3",
+ "version": "137.1.4-alpha.5",
"license": "MIT",
"dependencies": {
"base64-js": "1.5.1",
diff --git a/package.json b/package.json
index 4cee13ecb..ba8cc44c3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@stream-io/react-native-webrtc",
- "version": "137.1.3",
+ "version": "137.1.4-alpha.5",
"repository": {
"type": "git",
"url": "git+https://github.com/GetStream/react-native-webrtc.git"
diff --git a/src/AudioDeviceModule.ts b/src/AudioDeviceModule.ts
new file mode 100644
index 000000000..478c3e6c9
--- /dev/null
+++ b/src/AudioDeviceModule.ts
@@ -0,0 +1,217 @@
+import { NativeModules, Platform } from 'react-native';
+
+const { WebRTCModule } = NativeModules;
+
+export enum AudioEngineMuteMode {
+ Unknown = -1,
+ VoiceProcessing = 0,
+ RestartEngine = 1,
+ InputMixer = 2,
+}
+
+/**
+ * Returns the native WebRTCModule after verifying the platform is iOS/macOS.
+ * Throws on Android where these audio device module APIs are not available.
+ */
+const getAudioDeviceModule = () => {
+ if (Platform.OS === 'android') {
+ throw new Error('AudioDeviceModule is only available on iOS/macOS');
+ }
+
+ return WebRTCModule;
+};
+
+/**
+ * Audio Device Module API for controlling audio devices and settings.
+ * iOS/macOS only - will throw on Android.
+ */
+export class AudioDeviceModule {
+ /**
+ * Start audio playback
+ */
+ static async startPlayout(): Promise {
+ return getAudioDeviceModule().audioDeviceModuleStartPlayout();
+ }
+
+ /**
+ * Stop audio playback
+ */
+ static async stopPlayout(): Promise {
+ return getAudioDeviceModule().audioDeviceModuleStopPlayout();
+ }
+
+ /**
+ * Start audio recording
+ */
+ static async startRecording(): Promise {
+ return getAudioDeviceModule().audioDeviceModuleStartRecording();
+ }
+
+ /**
+ * Stop audio recording
+ */
+ static async stopRecording(): Promise {
+ return getAudioDeviceModule().audioDeviceModuleStopRecording();
+ }
+
+ /**
+ * Initialize and start local audio recording (calls initAndStartRecording)
+ */
+ static async startLocalRecording(): Promise {
+ return getAudioDeviceModule().audioDeviceModuleStartLocalRecording();
+ }
+
+ /**
+ * Stop local audio recording
+ */
+ static async stopLocalRecording(): Promise {
+ return getAudioDeviceModule().audioDeviceModuleStopLocalRecording();
+ }
+
+ /**
+ * Mute or unmute the microphone
+ */
+ static async setMicrophoneMuted(muted: boolean): Promise {
+ return getAudioDeviceModule().audioDeviceModuleSetMicrophoneMuted(muted);
+ }
+
+ /**
+ * Check if microphone is currently muted
+ */
+ static isMicrophoneMuted(): boolean {
+ return getAudioDeviceModule().audioDeviceModuleIsMicrophoneMuted();
+ }
+
+ /**
+ * Enable or disable voice processing (requires engine restart)
+ */
+ static async setVoiceProcessingEnabled(enabled: boolean): Promise {
+ return getAudioDeviceModule().audioDeviceModuleSetVoiceProcessingEnabled(enabled);
+ }
+
+ /**
+ * Check if voice processing is enabled
+ */
+ static isVoiceProcessingEnabled(): boolean {
+ return getAudioDeviceModule().audioDeviceModuleIsVoiceProcessingEnabled();
+ }
+
+ /**
+ * Temporarily bypass voice processing without restarting the engine
+ */
+ static setVoiceProcessingBypassed(bypassed: boolean): void {
+ getAudioDeviceModule().audioDeviceModuleSetVoiceProcessingBypassed(bypassed);
+ }
+
+ /**
+ * Check if voice processing is currently bypassed
+ */
+ static isVoiceProcessingBypassed(): boolean {
+ return getAudioDeviceModule().audioDeviceModuleIsVoiceProcessingBypassed();
+ }
+
+ /**
+ * Enable or disable Automatic Gain Control (AGC)
+ */
+ static setVoiceProcessingAGCEnabled(enabled: boolean): void {
+ return getAudioDeviceModule().audioDeviceModuleSetVoiceProcessingAGCEnabled(enabled);
+ }
+
+ /**
+ * Check if AGC is enabled
+ */
+ static isVoiceProcessingAGCEnabled(): boolean {
+ return getAudioDeviceModule().audioDeviceModuleIsVoiceProcessingAGCEnabled();
+ }
+
+ /**
+ * Check if audio is currently playing
+ */
+ static isPlaying(): boolean {
+ return getAudioDeviceModule().audioDeviceModuleIsPlaying();
+ }
+
+ /**
+ * Check if audio is currently recording
+ */
+ static isRecording(): boolean {
+ return getAudioDeviceModule().audioDeviceModuleIsRecording();
+ }
+
+ /**
+ * Check if the audio engine is running
+ */
+ static isEngineRunning(): boolean {
+ return getAudioDeviceModule().audioDeviceModuleIsEngineRunning();
+ }
+
+ /**
+ * Set the microphone mute mode
+ */
+ static async setMuteMode(mode: AudioEngineMuteMode): Promise {
+ return getAudioDeviceModule().audioDeviceModuleSetMuteMode(mode);
+ }
+
+ /**
+ * Get the current mute mode
+ */
+ static getMuteMode(): AudioEngineMuteMode {
+ return getAudioDeviceModule().audioDeviceModuleGetMuteMode();
+ }
+
+ /**
+ * Enable or disable advanced audio ducking
+ */
+ static setAdvancedDuckingEnabled(enabled: boolean): void {
+ return getAudioDeviceModule().audioDeviceModuleSetAdvancedDuckingEnabled(enabled);
+ }
+
+ /**
+ * Check if advanced ducking is enabled
+ */
+ static isAdvancedDuckingEnabled(): boolean {
+ return getAudioDeviceModule().audioDeviceModuleIsAdvancedDuckingEnabled();
+ }
+
+ /**
+ * Set the audio ducking level (0-100)
+ */
+ static setDuckingLevel(level: number): void {
+ getAudioDeviceModule();
+
+ if (typeof level !== 'number' || isNaN(level)) {
+ throw new TypeError(`setDuckingLevel: expected a number, got ${typeof level}`);
+ }
+
+ if (!Number.isInteger(level) || level < 0 || level > 100) {
+ throw new RangeError(`setDuckingLevel: level must be an integer between 0 and 100, got ${level}`);
+ }
+
+ return WebRTCModule.audioDeviceModuleSetDuckingLevel(level);
+ }
+
+ /**
+ * Get the current ducking level
+ */
+ static getDuckingLevel(): number {
+ return getAudioDeviceModule().audioDeviceModuleGetDuckingLevel();
+ }
+
+ /**
+ * Check if recording always prepared mode is enabled
+ */
+ static isRecordingAlwaysPreparedMode(): boolean {
+ return getAudioDeviceModule().audioDeviceModuleIsRecordingAlwaysPreparedMode();
+ }
+
+ /**
+ * Enable or disable recording always prepared mode
+ */
+ static async setRecordingAlwaysPreparedMode(enabled: boolean): Promise {
+ return getAudioDeviceModule().audioDeviceModuleSetRecordingAlwaysPreparedMode(enabled);
+ }
+
+ // TODO: getEngineAvailability / setEngineAvailability are not supported by the
+ // Stream WebRTC SDK (no RTCAudioEngineAvailability type / setEngineAvailability:
+ // method). Re-add if/when the native API lands.
+}
diff --git a/src/AudioDeviceModuleEvents.ts b/src/AudioDeviceModuleEvents.ts
index 190a62cde..c25b74b1b 100644
--- a/src/AudioDeviceModuleEvents.ts
+++ b/src/AudioDeviceModuleEvents.ts
@@ -1,4 +1,4 @@
-import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
+import { NativeEventEmitter, NativeModules } from 'react-native';
const { WebRTCModule } = NativeModules;
@@ -28,7 +28,8 @@ export type AudioDeviceModuleEventData =
/**
* Event emitter for RTCAudioDeviceModule delegate callbacks.
- * iOS/macOS only.
+ * Speech activity events are supported on iOS, macOS, and Android.
+ * Engine/audio-processing-state events remain iOS/macOS only.
*/
class AudioDeviceModuleEventEmitter {
private eventEmitter: NativeEventEmitter | null = null;
@@ -39,17 +40,18 @@ class AudioDeviceModuleEventEmitter {
return;
}
- if (Platform.OS !== 'android' && WebRTCModule) {
+ if (WebRTCModule) {
this.eventEmitter = new NativeEventEmitter(WebRTCModule);
}
}
/**
- * Subscribe to speech activity events (started/ended)
+ * Subscribe to speech activity events (started/ended).
+ * Supported on iOS, macOS, and Android.
*/
addSpeechActivityListener(listener: (data: SpeechActivityEventData) => void) {
if (!this.eventEmitter) {
- throw new Error('AudioDeviceModuleEvents is only available on iOS/macOS');
+ throw new Error('AudioDeviceModuleEvents: native module not available');
}
return this.eventEmitter.addListener('audioDeviceModuleSpeechActivity', listener);
diff --git a/src/index.ts b/src/index.ts
index 496c83b96..7d0983b12 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,6 +8,7 @@ if (WebRTCModule === null) {
}`);
}
+import { AudioDeviceModule, AudioEngineMuteMode } from './AudioDeviceModule';
import { audioDeviceModuleEvents } from './AudioDeviceModuleEvents';
import { setupNativeEvents } from './EventEmitter';
import Logger from './Logger';
@@ -52,6 +53,8 @@ export {
mediaDevices,
permissions,
registerGlobals,
+ AudioDeviceModule,
+ AudioEngineMuteMode,
audioDeviceModuleEvents,
};
@@ -83,4 +86,7 @@ function registerGlobals(): void {
global.RTCRtpReceiver = RTCRtpReceiver;
global.RTCRtpSender = RTCRtpSender;
global.RTCErrorEvent = RTCErrorEvent;
+
+ // Ensure audioDeviceModuleEvents is initialized and event listeners are registered
+ audioDeviceModuleEvents.setupListeners();
}