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: + *

    + *
  1. Every ~10 ms the mic gives us a chunk of samples.
  2. + *
  3. 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.
  4. + *
  5. Track two things only: when we last saw a loud chunk and + * when the current run of loud chunks started.
  6. + *
  7. 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.
  8. + *
+ * + *

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(); }