From 3957df71377b935f4f4da24120d8c84c0e321821 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Mon, 20 Apr 2026 12:26:31 +0200 Subject: [PATCH 1/3] fix: align remote track mute/unmute with W3C spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote tracks now start muted and fire unmute when first media data arrives, matching the W3C WebPlatformTest expectation that remoteTrack.muted is true inside ontrack. Video: mutedState starts true; first decoded frame fires unmute immediately (no 3s wait); the existing periodic timer continues to detect mid-call stalls. Audio (parity gap): new AudioTrackAdapter fires unmute on the first decoded PCM buffer via AudioTrackSink (Android) and RTCAudioRenderer (iOS). Note: only the initial mute → unmute is detectable; subsequent stalls cannot be observed from the sink because Android's audio render path and iOS NetEq synthesize silence / PLC frames when RTP stops. Documented inline. JS: MediaStreamTrack._muted defaults to true for remote tracks; _setMutedInternal now guards against duplicate events per mediacapture-main "set a track's muted state". A pending-mute buffer in RTCPeerConnection absorbs native events that arrive before the JS track is constructed in setRemoteDescription (fast/loopback races). --- .../oney/WebRTCModule/AudioTrackAdapter.java | 92 ++++++++++++++ .../WebRTCModule/PeerConnectionObserver.java | 7 +- .../oney/WebRTCModule/VideoTrackAdapter.java | 11 +- ios/RCTWebRTC.xcodeproj/project.pbxproj | 6 + .../WebRTCModule+AudioTrackAdapter.h | 13 ++ .../WebRTCModule+AudioTrackAdapter.m | 114 ++++++++++++++++++ .../WebRTCModule+RTCPeerConnection.m | 7 +- .../WebRTCModule+VideoTrackAdapter.m | 11 +- macos/RCTWebRTC.xcodeproj/project.pbxproj | 6 + src/MediaStreamTrack.ts | 10 +- src/RTCPeerConnection.ts | 28 ++++- 11 files changed, 296 insertions(+), 9 deletions(-) create mode 100644 android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java create mode 100644 ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h create mode 100644 ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m diff --git a/android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java b/android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java new file mode 100644 index 000000000..d9b39b34f --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java @@ -0,0 +1,92 @@ +package com.oney.WebRTCModule; + +import android.util.Log; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; + +import org.webrtc.AudioTrack; +import org.webrtc.AudioTrackSink; + +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Fires the W3C 'unmute' event on a remote audio track when the first + * decoded PCM buffer arrives via {@link AudioTrackSink}. + * + * IMPORTANT — only the initial muted → unmuted transition is detectable. + * Subsequent mute events (e.g. network stall mid-call) cannot be detected + * from the sink: Android's audio render path and WebRTC's NetEq synthesize + * silence / PLC frames whenever RTP stops, so {@code onData} keeps firing + * at a steady rate regardless of network state. For "remote participant + * muted their mic" UI, use the out-of-band participant state from your + * signaling layer — that is the correct source of truth, not this adapter. + * + * Only attach to remote audio tracks. {@code AudioTrackSink} callbacks + * are not delivered for local tracks. + */ +public class AudioTrackAdapter { + static final String TAG = AudioTrackAdapter.class.getCanonicalName(); + + private final Map sinks = new HashMap<>(); + private final int peerConnectionId; + private final WebRTCModule webRTCModule; + + public AudioTrackAdapter(WebRTCModule webRTCModule, int peerConnectionId) { + this.peerConnectionId = peerConnectionId; + this.webRTCModule = webRTCModule; + } + + public void addAdapter(AudioTrack audioTrack) { + String trackId = audioTrack.id(); + if (sinks.containsKey(trackId)) { + Log.w(TAG, "Attempted to add adapter twice for track ID: " + trackId); + return; + } + FirstDataUnmuteSink sink = new FirstDataUnmuteSink(trackId); + sinks.put(trackId, sink); + audioTrack.addSink(sink); + Log.d(TAG, "Created adapter for " + trackId); + } + + public void removeAdapter(AudioTrack audioTrack) { + String trackId = audioTrack.id(); + FirstDataUnmuteSink sink = sinks.remove(trackId); + if (sink == null) { + Log.w(TAG, "removeAdapter - no adapter for " + trackId); + return; + } + audioTrack.removeSink(sink); + Log.d(TAG, "Deleted adapter for " + trackId); + } + + private class FirstDataUnmuteSink implements AudioTrackSink { + private final AtomicBoolean fired = new AtomicBoolean(false); + private final String trackId; + + FirstDataUnmuteSink(String trackId) { + this.trackId = trackId; + } + + @Override + public void onData(ByteBuffer audioData, + int bitsPerSample, + int sampleRate, + int numberOfChannels, + int numberOfFrames, + long absoluteCaptureTimestampMs) { + if (!fired.compareAndSet(false, true)) { + return; + } + WritableMap params = Arguments.createMap(); + params.putInt("pcId", peerConnectionId); + params.putString("trackId", trackId); + params.putBoolean("muted", false); + Log.d(TAG, "Unmute event pcId: " + peerConnectionId + " trackId: " + trackId); + webRTCModule.sendEvent("mediaStreamTrackMuteChanged", params); + } + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java b/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java index 280568b24..fa3b4b472 100644 --- a/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java +++ b/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java @@ -43,6 +43,7 @@ class PeerConnectionObserver implements PeerConnection.Observer { final Map remoteStreams; // React tag -> MediaStream final Map remoteTracks; final VideoTrackAdapter videoTrackAdapters; + final AudioTrackAdapter audioTrackAdapters; private final WebRTCModule webRTCModule; PeerConnectionObserver(WebRTCModule webRTCModule, int id) { @@ -53,6 +54,7 @@ class PeerConnectionObserver implements PeerConnection.Observer { this.remoteStreams = new HashMap<>(); this.remoteTracks = new HashMap<>(); this.videoTrackAdapters = new VideoTrackAdapter(webRTCModule, id); + this.audioTrackAdapters = new AudioTrackAdapter(webRTCModule, id); } PeerConnection getPeerConnection() { @@ -72,11 +74,13 @@ void close() { void dispose() { Log.d(TAG, "PeerConnection.dispose() for " + id); - // Remove video track adapters + // Remove track adapters for remote tracks for (MediaStreamTrack track : this.remoteTracks.values()) { if (track instanceof VideoTrack) { videoTrackAdapters.removeAdapter((VideoTrack) track); videoTrackAdapters.removeDimensionDetector((VideoTrack) track); + } else if (track instanceof AudioTrack) { + audioTrackAdapters.removeAdapter((AudioTrack) track); } } @@ -463,6 +467,7 @@ public void onAddTrack(final RtpReceiver receiver, final MediaStream[] mediaStre videoTrackAdapters.addAdapter((VideoTrack) track); videoTrackAdapters.addDimensionDetector((VideoTrack) track); } else if (track.kind().equals(MediaStreamTrack.AUDIO_TRACK_KIND)) { + audioTrackAdapters.addAdapter((AudioTrack) track); ((AudioTrack) track).setVolume(WebRTCModuleOptions.getInstance().defaultTrackVolume); } remoteTracks.put(track.id(), track); diff --git a/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java b/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java index 343f70ab8..0fbcf39ba 100644 --- a/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java +++ b/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java @@ -101,7 +101,8 @@ private class TrackMuteUnmuteImpl implements VideoSink { private TimerTask emitMuteTask; private volatile boolean disposed; private AtomicInteger frameCounter; - private boolean mutedState; + // Per W3C spec, remote tracks MUST start muted. + private volatile boolean mutedState = true; private final String trackId; TrackMuteUnmuteImpl(String trackId) { @@ -111,7 +112,13 @@ private class TrackMuteUnmuteImpl implements VideoSink { @Override public void onFrame(VideoFrame frame) { - frameCounter.addAndGet(1); + // incrementAndGet() == 1 is the atomic "first frame" check — fire + // unmute immediately instead of waiting up to INITIAL_MUTE_DELAY + // for the periodic timer. + if (frameCounter.incrementAndGet() == 1 && mutedState) { + mutedState = false; + emitMuteEvent(false); + } } private void start() { diff --git a/ios/RCTWebRTC.xcodeproj/project.pbxproj b/ios/RCTWebRTC.xcodeproj/project.pbxproj index 49e26d1ce..f77ab97c6 100644 --- a/ios/RCTWebRTC.xcodeproj/project.pbxproj +++ b/ios/RCTWebRTC.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 4EE3A8BD25B8416500FAA24A /* WebRTCModule+RTCMediaStream.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8BC25B8416500FAA24A /* WebRTCModule+RTCMediaStream.m */; }; 4EE3A8C125B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8BF25B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.m */; }; 4EE3A8C525B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8C325B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m */; }; + A100000125B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = A100000225B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m */; }; 4EE3A8D125B841DD00FAA24A /* SocketConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8C825B841DD00FAA24A /* SocketConnection.m */; }; 4EE3A8D225B841DD00FAA24A /* CaptureController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8C925B841DD00FAA24A /* CaptureController.m */; }; 4EE3A8D325B841DD00FAA24A /* ScreenCaptureController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8CC25B841DD00FAA24A /* ScreenCaptureController.m */; }; @@ -64,6 +65,8 @@ 4EE3A8C025B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "WebRTCModule+RTCPeerConnection.h"; path = "RCTWebRTC/WebRTCModule+RTCPeerConnection.h"; sourceTree = SOURCE_ROOT; }; 4EE3A8C325B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "WebRTCModule+VideoTrackAdapter.m"; path = "RCTWebRTC/WebRTCModule+VideoTrackAdapter.m"; sourceTree = SOURCE_ROOT; }; 4EE3A8C425B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "WebRTCModule+VideoTrackAdapter.h"; path = "RCTWebRTC/WebRTCModule+VideoTrackAdapter.h"; sourceTree = SOURCE_ROOT; }; + A100000225B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "WebRTCModule+AudioTrackAdapter.m"; path = "RCTWebRTC/WebRTCModule+AudioTrackAdapter.m"; sourceTree = SOURCE_ROOT; }; + A100000325B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "WebRTCModule+AudioTrackAdapter.h"; path = "RCTWebRTC/WebRTCModule+AudioTrackAdapter.h"; sourceTree = SOURCE_ROOT; }; 4EE3A8C725B841DD00FAA24A /* ScreenCapturer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ScreenCapturer.h; path = RCTWebRTC/ScreenCapturer.h; sourceTree = SOURCE_ROOT; }; 4EE3A8C825B841DD00FAA24A /* SocketConnection.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = SocketConnection.m; path = RCTWebRTC/SocketConnection.m; sourceTree = SOURCE_ROOT; }; 4EE3A8C925B841DD00FAA24A /* CaptureController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CaptureController.m; path = RCTWebRTC/CaptureController.m; sourceTree = SOURCE_ROOT; }; @@ -142,6 +145,8 @@ 4EE3A8BF25B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.m */, 4EE3A8C425B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.h */, 4EE3A8C325B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m */, + A100000325B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.h */, + A100000225B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m */, 4EE3A8B125B8414000FAA24A /* WebRTCModule.h */, 4EE3A8B025B8414000FAA24A /* WebRTCModule.m */, 4EE3A8CB25B841DD00FAA24A /* CaptureController.h */, @@ -256,6 +261,7 @@ buildActionMask = 2147483647; files = ( 4EE3A8C525B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m in Sources */, + A100000125B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m in Sources */, 4EE3A8BA25B8415900FAA24A /* WebRTCModule+RTCDataChannel.m in Sources */, 4EE3A8B625B8414A00FAA24A /* WebRTCModule+Permissions.m in Sources */, DEC96577264176C10052DB35 /* DataChannelWrapper.m in Sources */, diff --git a/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h b/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h new file mode 100644 index 000000000..4108d73e6 --- /dev/null +++ b/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h @@ -0,0 +1,13 @@ + +#import +#import +#import "WebRTCModule.h" + +@interface RTCPeerConnection (AudioTrackAdapter) + +@property(nonatomic, strong) NSMutableDictionary *audioTrackAdapters; + +- (void)addAudioTrackAdapter:(RTCAudioTrack *)track; +- (void)removeAudioTrackAdapter:(RTCAudioTrack *)track; + +@end diff --git a/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m b/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m new file mode 100644 index 000000000..1dfd17726 --- /dev/null +++ b/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m @@ -0,0 +1,114 @@ + +#import +#import +#import +#import + +#import +#import +#import + +#import +#import + +#import "WebRTCModule+AudioTrackAdapter.h" +#import "WebRTCModule+RTCPeerConnection.h" +#import "WebRTCModule.h" + +/* Fires the W3C 'unmute' event on a remote audio track when the first + * decoded PCM buffer arrives via RTCAudioRenderer. + * + * IMPORTANT — only the initial muted → unmuted transition is detectable. + * Subsequent mute events (network stall mid-call) cannot be detected + * from the renderer: the iOS audio render path and WebRTC's NetEq + * synthesize silence / PLC frames whenever RTP stops, so + * renderPCMBuffer: keeps firing at a steady rate regardless of network + * state. For "remote participant muted their mic" UI, use the + * out-of-band participant state from your signaling layer — that is the + * correct source of truth, not this adapter. + */ +@interface FirstBufferUnmuteRenderer : NSObject + +@property(copy, nonatomic) NSNumber *peerConnectionId; +@property(copy, nonatomic) NSString *trackId; +@property(weak, nonatomic) WebRTCModule *module; + +- (instancetype)initWith:(NSNumber *)peerConnectionId + trackId:(NSString *)trackId + webRTCModule:(WebRTCModule *)module; + +@end + +@implementation FirstBufferUnmuteRenderer { + atomic_flag _fired; +} + +- (instancetype)initWith:(NSNumber *)peerConnectionId + trackId:(NSString *)trackId + webRTCModule:(WebRTCModule *)module { + self = [super init]; + if (self) { + self.peerConnectionId = peerConnectionId; + self.trackId = trackId; + self.module = module; + atomic_flag_clear(&_fired); + } + return self; +} + +- (void)renderPCMBuffer:(AVAudioPCMBuffer *)pcmBuffer { + if (atomic_flag_test_and_set(&_fired)) { + return; + } + [self.module sendEventWithName:kEventMediaStreamTrackMuteChanged + body:@{ + @"pcId" : self.peerConnectionId, + @"trackId" : self.trackId, + @"muted" : @NO + }]; + RCTLog(@"[AudioTrackAdapter] Unmute event for pc %@ track %@", self.peerConnectionId, self.trackId); +} + +@end + +@implementation RTCPeerConnection (AudioTrackAdapter) + +- (NSMutableDictionary *)audioTrackAdapters { + return objc_getAssociatedObject(self, _cmd); +} + +- (void)setAudioTrackAdapters:(NSMutableDictionary *)audioTrackAdapters { + objc_setAssociatedObject( + self, @selector(audioTrackAdapters), audioTrackAdapters, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (void)addAudioTrackAdapter:(RTCAudioTrack *)track { + NSString *trackId = track.trackId; + if ([self.audioTrackAdapters objectForKey:trackId] != nil) { + RCTLogWarn(@"[AudioTrackAdapter] Adapter already exists for track %@", trackId); + return; + } + + FirstBufferUnmuteRenderer *renderer = [[FirstBufferUnmuteRenderer alloc] initWith:self.reactTag + trackId:trackId + webRTCModule:self.webRTCModule]; + [self.audioTrackAdapters setObject:renderer forKey:trackId]; + [track addRenderer:renderer]; + + RCTLogTrace(@"[AudioTrackAdapter] Adapter created for track %@", trackId); +} + +- (void)removeAudioTrackAdapter:(RTCAudioTrack *)track { + NSString *trackId = track.trackId; + FirstBufferUnmuteRenderer *renderer = [self.audioTrackAdapters objectForKey:trackId]; + if (renderer == nil) { + RCTLogWarn(@"[AudioTrackAdapter] Adapter doesn't exist for track %@", trackId); + return; + } + + [track removeRenderer:renderer]; + [self.audioTrackAdapters removeObjectForKey:trackId]; + RCTLogTrace(@"[AudioTrackAdapter] Adapter removed for track %@", trackId); +} + +@end diff --git a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m index 38fd42279..3535c8ed6 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m +++ b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m @@ -18,6 +18,7 @@ #import #import "SerializeUtils.h" +#import "WebRTCModule+AudioTrackAdapter.h" #import "WebRTCModule+RTCDataChannel.h" #import "WebRTCModule+RTCPeerConnection.h" #import "WebRTCModule+VideoTrackAdapter.h" @@ -165,6 +166,7 @@ - (nullable RTCRtpTransceiver *)getTransceiverByPeerConnectionId:(nonnull NSNumb peerConnection.remoteTracks = [NSMutableDictionary new]; peerConnection.videoTrackAdapters = [NSMutableDictionary new]; peerConnection.videoDimensionDetectors = [NSMutableDictionary new]; + peerConnection.audioTrackAdapters = [NSMutableDictionary new]; peerConnection.webRTCModule = self; self.peerConnections[objectID] = peerConnection; @@ -395,12 +397,14 @@ - (nullable RTCRtpTransceiver *)getTransceiverByPeerConnectionId:(nonnull NSNumb return; } - // Remove video track adapters + // Remove track adapters for remote tracks for (NSString *key in peerConnection.remoteTracks.allKeys) { RTCMediaStreamTrack *track = peerConnection.remoteTracks[key]; if (track.kind == kRTCMediaStreamTrackKindVideo) { [peerConnection removeVideoTrackAdapter:(RTCVideoTrack *)track]; [peerConnection removeVideoDimensionDetector:(RTCVideoTrack *)track]; + } else if (track.kind == kRTCMediaStreamTrackKindAudio) { + [peerConnection removeAudioTrackAdapter:(RTCAudioTrack *)track]; } } @@ -981,6 +985,7 @@ - (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection [peerConnection addVideoDimensionDetector:videoTrack]; } else if (track.kind == kRTCMediaStreamTrackKindAudio) { RTCAudioTrack *audioTrack = (RTCAudioTrack *)track; + [peerConnection addAudioTrackAdapter:audioTrack]; WebRTCModuleOptions *options = [WebRTCModuleOptions sharedInstance]; audioTrack.source.volume = options.defaultTrackVolume; } diff --git a/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m b/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m index ccb8c31bf..f57f74aba 100644 --- a/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m +++ b/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m @@ -50,7 +50,8 @@ - (instancetype)initWith:(NSNumber *)peerConnectionId trackId:(NSString *)trackI _disposed = NO; _frameCount = 0; - _muted = NO; + // Per W3C spec, remote tracks MUST start muted. + _muted = YES; _timer = nil; } @@ -112,7 +113,13 @@ - (void)start { } - (void)renderFrame:(nullable RTCVideoFrame *)frame { - atomic_fetch_add(&_frameCount, 1); + // atomic_fetch_add returns the prior value; == 0 is the atomic "first + // frame" check — fire unmute immediately instead of waiting up to + // INITIAL_MUTE_DELAY for the periodic timer. + if (atomic_fetch_add(&_frameCount, 1) == 0 && self->_muted) { + self->_muted = NO; + [self emitMuteEvent:NO]; + } } - (void)setSize:(CGSize)size { diff --git a/macos/RCTWebRTC.xcodeproj/project.pbxproj b/macos/RCTWebRTC.xcodeproj/project.pbxproj index 9fca0a992..11c127345 100644 --- a/macos/RCTWebRTC.xcodeproj/project.pbxproj +++ b/macos/RCTWebRTC.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 0779C0B624D7C79B00E3B7C6 /* WebRTCModule+RTCPeerConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 35A2223F1CB493C00015FD5C /* WebRTCModule+RTCPeerConnection.m */; }; 0779C0B724D7C79B00E3B7C6 /* WebRTCModule+VideoTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = 0B56CFFF212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.m */; }; + A100000124D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = A100000324D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m */; }; 0779C0B824D7C79B00E3B7C6 /* WebRTCModule+RTCMediaStream.m in Sources */ = {isa = PBXBuildFile; fileRef = 35A2223D1CB493C00015FD5C /* WebRTCModule+RTCMediaStream.m */; }; 0779C0B924D7C79B00E3B7C6 /* VideoCaptureController.m in Sources */ = {isa = PBXBuildFile; fileRef = 0BDDA6DF20C18B6B00B38B45 /* VideoCaptureController.m */; }; 0779C0BA24D7C79B00E3B7C6 /* RTCMediaStreamTrack+React.m in Sources */ = {isa = PBXBuildFile; fileRef = 0BDDA6DC20C17E7C00B38B45 /* RTCMediaStreamTrack+React.m */; }; @@ -36,6 +37,8 @@ 0779C0C524D7C79B00E3B7C6 /* libRCTWebRTC-macos.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libRCTWebRTC-macos.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 0B56CFFE212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "WebRTCModule+VideoTrackAdapter.h"; sourceTree = ""; }; 0B56CFFF212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WebRTCModule+VideoTrackAdapter.m"; sourceTree = ""; }; + A100000224D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "WebRTCModule+AudioTrackAdapter.h"; sourceTree = ""; }; + A100000324D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WebRTCModule+AudioTrackAdapter.m"; sourceTree = ""; }; 0BC6C06B217F1D54005DCD37 /* WebRTCModule+Permissions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WebRTCModule+Permissions.m"; sourceTree = ""; }; 0BDDA6DC20C17E7C00B38B45 /* RTCMediaStreamTrack+React.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "RTCMediaStreamTrack+React.m"; sourceTree = ""; }; 0BDDA6DE20C17EA400B38B45 /* RTCMediaStreamTrack+React.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RTCMediaStreamTrack+React.h"; sourceTree = ""; }; @@ -103,6 +106,8 @@ 35A2223F1CB493C00015FD5C /* WebRTCModule+RTCPeerConnection.m */, 0B56CFFE212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.h */, 0B56CFFF212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.m */, + A100000224D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.h */, + A100000324D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m */, 35A222441CB493C00015FD5C /* WebRTCModule.h */, 35A222451CB493C00015FD5C /* WebRTCModule.m */, ); @@ -171,6 +176,7 @@ files = ( 0779C0B624D7C79B00E3B7C6 /* WebRTCModule+RTCPeerConnection.m in Sources */, 0779C0B724D7C79B00E3B7C6 /* WebRTCModule+VideoTrackAdapter.m in Sources */, + A100000124D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m in Sources */, 0779C0B824D7C79B00E3B7C6 /* WebRTCModule+RTCMediaStream.m in Sources */, 0779C0B924D7C79B00E3B7C6 /* VideoCaptureController.m in Sources */, 0779C0BA24D7C79B00E3B7C6 /* RTCMediaStreamTrack+React.m in Sources */, diff --git a/src/MediaStreamTrack.ts b/src/MediaStreamTrack.ts index 78cc17d12..9f84ddc80 100644 --- a/src/MediaStreamTrack.ts +++ b/src/MediaStreamTrack.ts @@ -61,7 +61,9 @@ export default class MediaStreamTrack extends EventTarget; _pendingTrackEvents: any[]; + // Native mute events that arrived before the matching JS track was + // constructed (can happen on fast/loopback connections). Drained when the + // remote track is created in setRemoteDescription. + _pendingMuteStates: Map; static generateCertificate( keygenAlgorithm: string | { @@ -173,6 +177,7 @@ export default class RTCPeerConnection extends EventTarget Date: Mon, 20 Apr 2026 12:41:42 +0200 Subject: [PATCH 2/3] Revert "fix: align remote track mute/unmute with W3C spec" This reverts commit 3957df71377b935f4f4da24120d8c84c0e321821. --- .../oney/WebRTCModule/AudioTrackAdapter.java | 92 -------------- .../WebRTCModule/PeerConnectionObserver.java | 7 +- .../oney/WebRTCModule/VideoTrackAdapter.java | 11 +- ios/RCTWebRTC.xcodeproj/project.pbxproj | 6 - .../WebRTCModule+AudioTrackAdapter.h | 13 -- .../WebRTCModule+AudioTrackAdapter.m | 114 ------------------ .../WebRTCModule+RTCPeerConnection.m | 7 +- .../WebRTCModule+VideoTrackAdapter.m | 11 +- macos/RCTWebRTC.xcodeproj/project.pbxproj | 6 - src/MediaStreamTrack.ts | 10 +- src/RTCPeerConnection.ts | 28 +---- 11 files changed, 9 insertions(+), 296 deletions(-) delete mode 100644 android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java delete mode 100644 ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h delete mode 100644 ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m diff --git a/android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java b/android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java deleted file mode 100644 index d9b39b34f..000000000 --- a/android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.oney.WebRTCModule; - -import android.util.Log; - -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.WritableMap; - -import org.webrtc.AudioTrack; -import org.webrtc.AudioTrackSink; - -import java.nio.ByteBuffer; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * Fires the W3C 'unmute' event on a remote audio track when the first - * decoded PCM buffer arrives via {@link AudioTrackSink}. - * - * IMPORTANT — only the initial muted → unmuted transition is detectable. - * Subsequent mute events (e.g. network stall mid-call) cannot be detected - * from the sink: Android's audio render path and WebRTC's NetEq synthesize - * silence / PLC frames whenever RTP stops, so {@code onData} keeps firing - * at a steady rate regardless of network state. For "remote participant - * muted their mic" UI, use the out-of-band participant state from your - * signaling layer — that is the correct source of truth, not this adapter. - * - * Only attach to remote audio tracks. {@code AudioTrackSink} callbacks - * are not delivered for local tracks. - */ -public class AudioTrackAdapter { - static final String TAG = AudioTrackAdapter.class.getCanonicalName(); - - private final Map sinks = new HashMap<>(); - private final int peerConnectionId; - private final WebRTCModule webRTCModule; - - public AudioTrackAdapter(WebRTCModule webRTCModule, int peerConnectionId) { - this.peerConnectionId = peerConnectionId; - this.webRTCModule = webRTCModule; - } - - public void addAdapter(AudioTrack audioTrack) { - String trackId = audioTrack.id(); - if (sinks.containsKey(trackId)) { - Log.w(TAG, "Attempted to add adapter twice for track ID: " + trackId); - return; - } - FirstDataUnmuteSink sink = new FirstDataUnmuteSink(trackId); - sinks.put(trackId, sink); - audioTrack.addSink(sink); - Log.d(TAG, "Created adapter for " + trackId); - } - - public void removeAdapter(AudioTrack audioTrack) { - String trackId = audioTrack.id(); - FirstDataUnmuteSink sink = sinks.remove(trackId); - if (sink == null) { - Log.w(TAG, "removeAdapter - no adapter for " + trackId); - return; - } - audioTrack.removeSink(sink); - Log.d(TAG, "Deleted adapter for " + trackId); - } - - private class FirstDataUnmuteSink implements AudioTrackSink { - private final AtomicBoolean fired = new AtomicBoolean(false); - private final String trackId; - - FirstDataUnmuteSink(String trackId) { - this.trackId = trackId; - } - - @Override - public void onData(ByteBuffer audioData, - int bitsPerSample, - int sampleRate, - int numberOfChannels, - int numberOfFrames, - long absoluteCaptureTimestampMs) { - if (!fired.compareAndSet(false, true)) { - return; - } - WritableMap params = Arguments.createMap(); - params.putInt("pcId", peerConnectionId); - params.putString("trackId", trackId); - params.putBoolean("muted", false); - Log.d(TAG, "Unmute event pcId: " + peerConnectionId + " trackId: " + trackId); - webRTCModule.sendEvent("mediaStreamTrackMuteChanged", params); - } - } -} diff --git a/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java b/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java index fa3b4b472..280568b24 100644 --- a/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java +++ b/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java @@ -43,7 +43,6 @@ class PeerConnectionObserver implements PeerConnection.Observer { final Map remoteStreams; // React tag -> MediaStream final Map remoteTracks; final VideoTrackAdapter videoTrackAdapters; - final AudioTrackAdapter audioTrackAdapters; private final WebRTCModule webRTCModule; PeerConnectionObserver(WebRTCModule webRTCModule, int id) { @@ -54,7 +53,6 @@ class PeerConnectionObserver implements PeerConnection.Observer { this.remoteStreams = new HashMap<>(); this.remoteTracks = new HashMap<>(); this.videoTrackAdapters = new VideoTrackAdapter(webRTCModule, id); - this.audioTrackAdapters = new AudioTrackAdapter(webRTCModule, id); } PeerConnection getPeerConnection() { @@ -74,13 +72,11 @@ void close() { void dispose() { Log.d(TAG, "PeerConnection.dispose() for " + id); - // Remove track adapters for remote tracks + // Remove video track adapters for (MediaStreamTrack track : this.remoteTracks.values()) { if (track instanceof VideoTrack) { videoTrackAdapters.removeAdapter((VideoTrack) track); videoTrackAdapters.removeDimensionDetector((VideoTrack) track); - } else if (track instanceof AudioTrack) { - audioTrackAdapters.removeAdapter((AudioTrack) track); } } @@ -467,7 +463,6 @@ public void onAddTrack(final RtpReceiver receiver, final MediaStream[] mediaStre videoTrackAdapters.addAdapter((VideoTrack) track); videoTrackAdapters.addDimensionDetector((VideoTrack) track); } else if (track.kind().equals(MediaStreamTrack.AUDIO_TRACK_KIND)) { - audioTrackAdapters.addAdapter((AudioTrack) track); ((AudioTrack) track).setVolume(WebRTCModuleOptions.getInstance().defaultTrackVolume); } remoteTracks.put(track.id(), track); diff --git a/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java b/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java index 0fbcf39ba..343f70ab8 100644 --- a/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java +++ b/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java @@ -101,8 +101,7 @@ private class TrackMuteUnmuteImpl implements VideoSink { private TimerTask emitMuteTask; private volatile boolean disposed; private AtomicInteger frameCounter; - // Per W3C spec, remote tracks MUST start muted. - private volatile boolean mutedState = true; + private boolean mutedState; private final String trackId; TrackMuteUnmuteImpl(String trackId) { @@ -112,13 +111,7 @@ private class TrackMuteUnmuteImpl implements VideoSink { @Override public void onFrame(VideoFrame frame) { - // incrementAndGet() == 1 is the atomic "first frame" check — fire - // unmute immediately instead of waiting up to INITIAL_MUTE_DELAY - // for the periodic timer. - if (frameCounter.incrementAndGet() == 1 && mutedState) { - mutedState = false; - emitMuteEvent(false); - } + frameCounter.addAndGet(1); } private void start() { diff --git a/ios/RCTWebRTC.xcodeproj/project.pbxproj b/ios/RCTWebRTC.xcodeproj/project.pbxproj index f77ab97c6..49e26d1ce 100644 --- a/ios/RCTWebRTC.xcodeproj/project.pbxproj +++ b/ios/RCTWebRTC.xcodeproj/project.pbxproj @@ -18,7 +18,6 @@ 4EE3A8BD25B8416500FAA24A /* WebRTCModule+RTCMediaStream.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8BC25B8416500FAA24A /* WebRTCModule+RTCMediaStream.m */; }; 4EE3A8C125B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8BF25B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.m */; }; 4EE3A8C525B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8C325B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m */; }; - A100000125B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = A100000225B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m */; }; 4EE3A8D125B841DD00FAA24A /* SocketConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8C825B841DD00FAA24A /* SocketConnection.m */; }; 4EE3A8D225B841DD00FAA24A /* CaptureController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8C925B841DD00FAA24A /* CaptureController.m */; }; 4EE3A8D325B841DD00FAA24A /* ScreenCaptureController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8CC25B841DD00FAA24A /* ScreenCaptureController.m */; }; @@ -65,8 +64,6 @@ 4EE3A8C025B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "WebRTCModule+RTCPeerConnection.h"; path = "RCTWebRTC/WebRTCModule+RTCPeerConnection.h"; sourceTree = SOURCE_ROOT; }; 4EE3A8C325B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "WebRTCModule+VideoTrackAdapter.m"; path = "RCTWebRTC/WebRTCModule+VideoTrackAdapter.m"; sourceTree = SOURCE_ROOT; }; 4EE3A8C425B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "WebRTCModule+VideoTrackAdapter.h"; path = "RCTWebRTC/WebRTCModule+VideoTrackAdapter.h"; sourceTree = SOURCE_ROOT; }; - A100000225B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "WebRTCModule+AudioTrackAdapter.m"; path = "RCTWebRTC/WebRTCModule+AudioTrackAdapter.m"; sourceTree = SOURCE_ROOT; }; - A100000325B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "WebRTCModule+AudioTrackAdapter.h"; path = "RCTWebRTC/WebRTCModule+AudioTrackAdapter.h"; sourceTree = SOURCE_ROOT; }; 4EE3A8C725B841DD00FAA24A /* ScreenCapturer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ScreenCapturer.h; path = RCTWebRTC/ScreenCapturer.h; sourceTree = SOURCE_ROOT; }; 4EE3A8C825B841DD00FAA24A /* SocketConnection.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = SocketConnection.m; path = RCTWebRTC/SocketConnection.m; sourceTree = SOURCE_ROOT; }; 4EE3A8C925B841DD00FAA24A /* CaptureController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CaptureController.m; path = RCTWebRTC/CaptureController.m; sourceTree = SOURCE_ROOT; }; @@ -145,8 +142,6 @@ 4EE3A8BF25B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.m */, 4EE3A8C425B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.h */, 4EE3A8C325B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m */, - A100000325B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.h */, - A100000225B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m */, 4EE3A8B125B8414000FAA24A /* WebRTCModule.h */, 4EE3A8B025B8414000FAA24A /* WebRTCModule.m */, 4EE3A8CB25B841DD00FAA24A /* CaptureController.h */, @@ -261,7 +256,6 @@ buildActionMask = 2147483647; files = ( 4EE3A8C525B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m in Sources */, - A100000125B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m in Sources */, 4EE3A8BA25B8415900FAA24A /* WebRTCModule+RTCDataChannel.m in Sources */, 4EE3A8B625B8414A00FAA24A /* WebRTCModule+Permissions.m in Sources */, DEC96577264176C10052DB35 /* DataChannelWrapper.m in Sources */, diff --git a/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h b/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h deleted file mode 100644 index 4108d73e6..000000000 --- a/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h +++ /dev/null @@ -1,13 +0,0 @@ - -#import -#import -#import "WebRTCModule.h" - -@interface RTCPeerConnection (AudioTrackAdapter) - -@property(nonatomic, strong) NSMutableDictionary *audioTrackAdapters; - -- (void)addAudioTrackAdapter:(RTCAudioTrack *)track; -- (void)removeAudioTrackAdapter:(RTCAudioTrack *)track; - -@end diff --git a/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m b/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m deleted file mode 100644 index 1dfd17726..000000000 --- a/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m +++ /dev/null @@ -1,114 +0,0 @@ - -#import -#import -#import -#import - -#import -#import -#import - -#import -#import - -#import "WebRTCModule+AudioTrackAdapter.h" -#import "WebRTCModule+RTCPeerConnection.h" -#import "WebRTCModule.h" - -/* Fires the W3C 'unmute' event on a remote audio track when the first - * decoded PCM buffer arrives via RTCAudioRenderer. - * - * IMPORTANT — only the initial muted → unmuted transition is detectable. - * Subsequent mute events (network stall mid-call) cannot be detected - * from the renderer: the iOS audio render path and WebRTC's NetEq - * synthesize silence / PLC frames whenever RTP stops, so - * renderPCMBuffer: keeps firing at a steady rate regardless of network - * state. For "remote participant muted their mic" UI, use the - * out-of-band participant state from your signaling layer — that is the - * correct source of truth, not this adapter. - */ -@interface FirstBufferUnmuteRenderer : NSObject - -@property(copy, nonatomic) NSNumber *peerConnectionId; -@property(copy, nonatomic) NSString *trackId; -@property(weak, nonatomic) WebRTCModule *module; - -- (instancetype)initWith:(NSNumber *)peerConnectionId - trackId:(NSString *)trackId - webRTCModule:(WebRTCModule *)module; - -@end - -@implementation FirstBufferUnmuteRenderer { - atomic_flag _fired; -} - -- (instancetype)initWith:(NSNumber *)peerConnectionId - trackId:(NSString *)trackId - webRTCModule:(WebRTCModule *)module { - self = [super init]; - if (self) { - self.peerConnectionId = peerConnectionId; - self.trackId = trackId; - self.module = module; - atomic_flag_clear(&_fired); - } - return self; -} - -- (void)renderPCMBuffer:(AVAudioPCMBuffer *)pcmBuffer { - if (atomic_flag_test_and_set(&_fired)) { - return; - } - [self.module sendEventWithName:kEventMediaStreamTrackMuteChanged - body:@{ - @"pcId" : self.peerConnectionId, - @"trackId" : self.trackId, - @"muted" : @NO - }]; - RCTLog(@"[AudioTrackAdapter] Unmute event for pc %@ track %@", self.peerConnectionId, self.trackId); -} - -@end - -@implementation RTCPeerConnection (AudioTrackAdapter) - -- (NSMutableDictionary *)audioTrackAdapters { - return objc_getAssociatedObject(self, _cmd); -} - -- (void)setAudioTrackAdapters:(NSMutableDictionary *)audioTrackAdapters { - objc_setAssociatedObject( - self, @selector(audioTrackAdapters), audioTrackAdapters, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (void)addAudioTrackAdapter:(RTCAudioTrack *)track { - NSString *trackId = track.trackId; - if ([self.audioTrackAdapters objectForKey:trackId] != nil) { - RCTLogWarn(@"[AudioTrackAdapter] Adapter already exists for track %@", trackId); - return; - } - - FirstBufferUnmuteRenderer *renderer = [[FirstBufferUnmuteRenderer alloc] initWith:self.reactTag - trackId:trackId - webRTCModule:self.webRTCModule]; - [self.audioTrackAdapters setObject:renderer forKey:trackId]; - [track addRenderer:renderer]; - - RCTLogTrace(@"[AudioTrackAdapter] Adapter created for track %@", trackId); -} - -- (void)removeAudioTrackAdapter:(RTCAudioTrack *)track { - NSString *trackId = track.trackId; - FirstBufferUnmuteRenderer *renderer = [self.audioTrackAdapters objectForKey:trackId]; - if (renderer == nil) { - RCTLogWarn(@"[AudioTrackAdapter] Adapter doesn't exist for track %@", trackId); - return; - } - - [track removeRenderer:renderer]; - [self.audioTrackAdapters removeObjectForKey:trackId]; - RCTLogTrace(@"[AudioTrackAdapter] Adapter removed for track %@", trackId); -} - -@end diff --git a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m index 3535c8ed6..38fd42279 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m +++ b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m @@ -18,7 +18,6 @@ #import #import "SerializeUtils.h" -#import "WebRTCModule+AudioTrackAdapter.h" #import "WebRTCModule+RTCDataChannel.h" #import "WebRTCModule+RTCPeerConnection.h" #import "WebRTCModule+VideoTrackAdapter.h" @@ -166,7 +165,6 @@ - (nullable RTCRtpTransceiver *)getTransceiverByPeerConnectionId:(nonnull NSNumb peerConnection.remoteTracks = [NSMutableDictionary new]; peerConnection.videoTrackAdapters = [NSMutableDictionary new]; peerConnection.videoDimensionDetectors = [NSMutableDictionary new]; - peerConnection.audioTrackAdapters = [NSMutableDictionary new]; peerConnection.webRTCModule = self; self.peerConnections[objectID] = peerConnection; @@ -397,14 +395,12 @@ - (nullable RTCRtpTransceiver *)getTransceiverByPeerConnectionId:(nonnull NSNumb return; } - // Remove track adapters for remote tracks + // Remove video track adapters for (NSString *key in peerConnection.remoteTracks.allKeys) { RTCMediaStreamTrack *track = peerConnection.remoteTracks[key]; if (track.kind == kRTCMediaStreamTrackKindVideo) { [peerConnection removeVideoTrackAdapter:(RTCVideoTrack *)track]; [peerConnection removeVideoDimensionDetector:(RTCVideoTrack *)track]; - } else if (track.kind == kRTCMediaStreamTrackKindAudio) { - [peerConnection removeAudioTrackAdapter:(RTCAudioTrack *)track]; } } @@ -985,7 +981,6 @@ - (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection [peerConnection addVideoDimensionDetector:videoTrack]; } else if (track.kind == kRTCMediaStreamTrackKindAudio) { RTCAudioTrack *audioTrack = (RTCAudioTrack *)track; - [peerConnection addAudioTrackAdapter:audioTrack]; WebRTCModuleOptions *options = [WebRTCModuleOptions sharedInstance]; audioTrack.source.volume = options.defaultTrackVolume; } diff --git a/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m b/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m index f57f74aba..ccb8c31bf 100644 --- a/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m +++ b/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m @@ -50,8 +50,7 @@ - (instancetype)initWith:(NSNumber *)peerConnectionId trackId:(NSString *)trackI _disposed = NO; _frameCount = 0; - // Per W3C spec, remote tracks MUST start muted. - _muted = YES; + _muted = NO; _timer = nil; } @@ -113,13 +112,7 @@ - (void)start { } - (void)renderFrame:(nullable RTCVideoFrame *)frame { - // atomic_fetch_add returns the prior value; == 0 is the atomic "first - // frame" check — fire unmute immediately instead of waiting up to - // INITIAL_MUTE_DELAY for the periodic timer. - if (atomic_fetch_add(&_frameCount, 1) == 0 && self->_muted) { - self->_muted = NO; - [self emitMuteEvent:NO]; - } + atomic_fetch_add(&_frameCount, 1); } - (void)setSize:(CGSize)size { diff --git a/macos/RCTWebRTC.xcodeproj/project.pbxproj b/macos/RCTWebRTC.xcodeproj/project.pbxproj index 11c127345..9fca0a992 100644 --- a/macos/RCTWebRTC.xcodeproj/project.pbxproj +++ b/macos/RCTWebRTC.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 0779C0B624D7C79B00E3B7C6 /* WebRTCModule+RTCPeerConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 35A2223F1CB493C00015FD5C /* WebRTCModule+RTCPeerConnection.m */; }; 0779C0B724D7C79B00E3B7C6 /* WebRTCModule+VideoTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = 0B56CFFF212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.m */; }; - A100000124D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = A100000324D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m */; }; 0779C0B824D7C79B00E3B7C6 /* WebRTCModule+RTCMediaStream.m in Sources */ = {isa = PBXBuildFile; fileRef = 35A2223D1CB493C00015FD5C /* WebRTCModule+RTCMediaStream.m */; }; 0779C0B924D7C79B00E3B7C6 /* VideoCaptureController.m in Sources */ = {isa = PBXBuildFile; fileRef = 0BDDA6DF20C18B6B00B38B45 /* VideoCaptureController.m */; }; 0779C0BA24D7C79B00E3B7C6 /* RTCMediaStreamTrack+React.m in Sources */ = {isa = PBXBuildFile; fileRef = 0BDDA6DC20C17E7C00B38B45 /* RTCMediaStreamTrack+React.m */; }; @@ -37,8 +36,6 @@ 0779C0C524D7C79B00E3B7C6 /* libRCTWebRTC-macos.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libRCTWebRTC-macos.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 0B56CFFE212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "WebRTCModule+VideoTrackAdapter.h"; sourceTree = ""; }; 0B56CFFF212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WebRTCModule+VideoTrackAdapter.m"; sourceTree = ""; }; - A100000224D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "WebRTCModule+AudioTrackAdapter.h"; sourceTree = ""; }; - A100000324D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WebRTCModule+AudioTrackAdapter.m"; sourceTree = ""; }; 0BC6C06B217F1D54005DCD37 /* WebRTCModule+Permissions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WebRTCModule+Permissions.m"; sourceTree = ""; }; 0BDDA6DC20C17E7C00B38B45 /* RTCMediaStreamTrack+React.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "RTCMediaStreamTrack+React.m"; sourceTree = ""; }; 0BDDA6DE20C17EA400B38B45 /* RTCMediaStreamTrack+React.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RTCMediaStreamTrack+React.h"; sourceTree = ""; }; @@ -106,8 +103,6 @@ 35A2223F1CB493C00015FD5C /* WebRTCModule+RTCPeerConnection.m */, 0B56CFFE212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.h */, 0B56CFFF212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.m */, - A100000224D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.h */, - A100000324D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m */, 35A222441CB493C00015FD5C /* WebRTCModule.h */, 35A222451CB493C00015FD5C /* WebRTCModule.m */, ); @@ -176,7 +171,6 @@ files = ( 0779C0B624D7C79B00E3B7C6 /* WebRTCModule+RTCPeerConnection.m in Sources */, 0779C0B724D7C79B00E3B7C6 /* WebRTCModule+VideoTrackAdapter.m in Sources */, - A100000124D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m in Sources */, 0779C0B824D7C79B00E3B7C6 /* WebRTCModule+RTCMediaStream.m in Sources */, 0779C0B924D7C79B00E3B7C6 /* VideoCaptureController.m in Sources */, 0779C0BA24D7C79B00E3B7C6 /* RTCMediaStreamTrack+React.m in Sources */, diff --git a/src/MediaStreamTrack.ts b/src/MediaStreamTrack.ts index 9f84ddc80..78cc17d12 100644 --- a/src/MediaStreamTrack.ts +++ b/src/MediaStreamTrack.ts @@ -61,9 +61,7 @@ export default class MediaStreamTrack extends EventTarget; _pendingTrackEvents: any[]; - // Native mute events that arrived before the matching JS track was - // constructed (can happen on fast/loopback connections). Drained when the - // remote track is created in setRemoteDescription. - _pendingMuteStates: Map; static generateCertificate( keygenAlgorithm: string | { @@ -177,7 +173,6 @@ export default class RTCPeerConnection extends EventTarget Date: Mon, 20 Apr 2026 12:26:31 +0200 Subject: [PATCH 3/3] fix: align remote track mute/unmute with W3C spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote tracks now start muted and fire unmute when first media data arrives, matching the W3C WebPlatformTest expectation that remoteTrack.muted is true inside ontrack. Video: mutedState starts true; first decoded frame fires unmute immediately (no 3s wait); the existing periodic timer continues to detect mid-call stalls. Audio (parity gap): new AudioTrackAdapter fires unmute on the first decoded PCM buffer via AudioTrackSink (Android) and RTCAudioRenderer (iOS). Note: only the initial mute → unmute is detectable; subsequent stalls cannot be observed from the sink because Android's audio render path and iOS NetEq synthesize silence / PLC frames when RTP stops. Documented inline. JS: MediaStreamTrack._muted defaults to true for remote tracks; _setMutedInternal now guards against duplicate events per mediacapture-main "set a track's muted state". A pending-mute buffer in RTCPeerConnection absorbs native events that arrive before the JS track is constructed in setRemoteDescription (fast/loopback races). --- .../oney/WebRTCModule/AudioTrackAdapter.java | 92 ++++++++++++++ .../WebRTCModule/PeerConnectionObserver.java | 7 +- .../oney/WebRTCModule/VideoTrackAdapter.java | 11 +- ios/RCTWebRTC.xcodeproj/project.pbxproj | 6 + .../WebRTCModule+AudioTrackAdapter.h | 13 ++ .../WebRTCModule+AudioTrackAdapter.m | 114 ++++++++++++++++++ .../WebRTCModule+RTCPeerConnection.m | 7 +- .../WebRTCModule+VideoTrackAdapter.m | 11 +- macos/RCTWebRTC.xcodeproj/project.pbxproj | 6 + src/MediaStreamTrack.ts | 10 +- src/RTCPeerConnection.ts | 28 ++++- 11 files changed, 296 insertions(+), 9 deletions(-) create mode 100644 android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java create mode 100644 ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h create mode 100644 ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m diff --git a/android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java b/android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java new file mode 100644 index 000000000..d9b39b34f --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/AudioTrackAdapter.java @@ -0,0 +1,92 @@ +package com.oney.WebRTCModule; + +import android.util.Log; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; + +import org.webrtc.AudioTrack; +import org.webrtc.AudioTrackSink; + +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Fires the W3C 'unmute' event on a remote audio track when the first + * decoded PCM buffer arrives via {@link AudioTrackSink}. + * + * IMPORTANT — only the initial muted → unmuted transition is detectable. + * Subsequent mute events (e.g. network stall mid-call) cannot be detected + * from the sink: Android's audio render path and WebRTC's NetEq synthesize + * silence / PLC frames whenever RTP stops, so {@code onData} keeps firing + * at a steady rate regardless of network state. For "remote participant + * muted their mic" UI, use the out-of-band participant state from your + * signaling layer — that is the correct source of truth, not this adapter. + * + * Only attach to remote audio tracks. {@code AudioTrackSink} callbacks + * are not delivered for local tracks. + */ +public class AudioTrackAdapter { + static final String TAG = AudioTrackAdapter.class.getCanonicalName(); + + private final Map sinks = new HashMap<>(); + private final int peerConnectionId; + private final WebRTCModule webRTCModule; + + public AudioTrackAdapter(WebRTCModule webRTCModule, int peerConnectionId) { + this.peerConnectionId = peerConnectionId; + this.webRTCModule = webRTCModule; + } + + public void addAdapter(AudioTrack audioTrack) { + String trackId = audioTrack.id(); + if (sinks.containsKey(trackId)) { + Log.w(TAG, "Attempted to add adapter twice for track ID: " + trackId); + return; + } + FirstDataUnmuteSink sink = new FirstDataUnmuteSink(trackId); + sinks.put(trackId, sink); + audioTrack.addSink(sink); + Log.d(TAG, "Created adapter for " + trackId); + } + + public void removeAdapter(AudioTrack audioTrack) { + String trackId = audioTrack.id(); + FirstDataUnmuteSink sink = sinks.remove(trackId); + if (sink == null) { + Log.w(TAG, "removeAdapter - no adapter for " + trackId); + return; + } + audioTrack.removeSink(sink); + Log.d(TAG, "Deleted adapter for " + trackId); + } + + private class FirstDataUnmuteSink implements AudioTrackSink { + private final AtomicBoolean fired = new AtomicBoolean(false); + private final String trackId; + + FirstDataUnmuteSink(String trackId) { + this.trackId = trackId; + } + + @Override + public void onData(ByteBuffer audioData, + int bitsPerSample, + int sampleRate, + int numberOfChannels, + int numberOfFrames, + long absoluteCaptureTimestampMs) { + if (!fired.compareAndSet(false, true)) { + return; + } + WritableMap params = Arguments.createMap(); + params.putInt("pcId", peerConnectionId); + params.putString("trackId", trackId); + params.putBoolean("muted", false); + Log.d(TAG, "Unmute event pcId: " + peerConnectionId + " trackId: " + trackId); + webRTCModule.sendEvent("mediaStreamTrackMuteChanged", params); + } + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java b/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java index 280568b24..fa3b4b472 100644 --- a/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java +++ b/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java @@ -43,6 +43,7 @@ class PeerConnectionObserver implements PeerConnection.Observer { final Map remoteStreams; // React tag -> MediaStream final Map remoteTracks; final VideoTrackAdapter videoTrackAdapters; + final AudioTrackAdapter audioTrackAdapters; private final WebRTCModule webRTCModule; PeerConnectionObserver(WebRTCModule webRTCModule, int id) { @@ -53,6 +54,7 @@ class PeerConnectionObserver implements PeerConnection.Observer { this.remoteStreams = new HashMap<>(); this.remoteTracks = new HashMap<>(); this.videoTrackAdapters = new VideoTrackAdapter(webRTCModule, id); + this.audioTrackAdapters = new AudioTrackAdapter(webRTCModule, id); } PeerConnection getPeerConnection() { @@ -72,11 +74,13 @@ void close() { void dispose() { Log.d(TAG, "PeerConnection.dispose() for " + id); - // Remove video track adapters + // Remove track adapters for remote tracks for (MediaStreamTrack track : this.remoteTracks.values()) { if (track instanceof VideoTrack) { videoTrackAdapters.removeAdapter((VideoTrack) track); videoTrackAdapters.removeDimensionDetector((VideoTrack) track); + } else if (track instanceof AudioTrack) { + audioTrackAdapters.removeAdapter((AudioTrack) track); } } @@ -463,6 +467,7 @@ public void onAddTrack(final RtpReceiver receiver, final MediaStream[] mediaStre videoTrackAdapters.addAdapter((VideoTrack) track); videoTrackAdapters.addDimensionDetector((VideoTrack) track); } else if (track.kind().equals(MediaStreamTrack.AUDIO_TRACK_KIND)) { + audioTrackAdapters.addAdapter((AudioTrack) track); ((AudioTrack) track).setVolume(WebRTCModuleOptions.getInstance().defaultTrackVolume); } remoteTracks.put(track.id(), track); diff --git a/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java b/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java index 343f70ab8..0fbcf39ba 100644 --- a/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java +++ b/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java @@ -101,7 +101,8 @@ private class TrackMuteUnmuteImpl implements VideoSink { private TimerTask emitMuteTask; private volatile boolean disposed; private AtomicInteger frameCounter; - private boolean mutedState; + // Per W3C spec, remote tracks MUST start muted. + private volatile boolean mutedState = true; private final String trackId; TrackMuteUnmuteImpl(String trackId) { @@ -111,7 +112,13 @@ private class TrackMuteUnmuteImpl implements VideoSink { @Override public void onFrame(VideoFrame frame) { - frameCounter.addAndGet(1); + // incrementAndGet() == 1 is the atomic "first frame" check — fire + // unmute immediately instead of waiting up to INITIAL_MUTE_DELAY + // for the periodic timer. + if (frameCounter.incrementAndGet() == 1 && mutedState) { + mutedState = false; + emitMuteEvent(false); + } } private void start() { diff --git a/ios/RCTWebRTC.xcodeproj/project.pbxproj b/ios/RCTWebRTC.xcodeproj/project.pbxproj index 49e26d1ce..f77ab97c6 100644 --- a/ios/RCTWebRTC.xcodeproj/project.pbxproj +++ b/ios/RCTWebRTC.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 4EE3A8BD25B8416500FAA24A /* WebRTCModule+RTCMediaStream.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8BC25B8416500FAA24A /* WebRTCModule+RTCMediaStream.m */; }; 4EE3A8C125B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8BF25B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.m */; }; 4EE3A8C525B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8C325B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m */; }; + A100000125B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = A100000225B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m */; }; 4EE3A8D125B841DD00FAA24A /* SocketConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8C825B841DD00FAA24A /* SocketConnection.m */; }; 4EE3A8D225B841DD00FAA24A /* CaptureController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8C925B841DD00FAA24A /* CaptureController.m */; }; 4EE3A8D325B841DD00FAA24A /* ScreenCaptureController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EE3A8CC25B841DD00FAA24A /* ScreenCaptureController.m */; }; @@ -64,6 +65,8 @@ 4EE3A8C025B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "WebRTCModule+RTCPeerConnection.h"; path = "RCTWebRTC/WebRTCModule+RTCPeerConnection.h"; sourceTree = SOURCE_ROOT; }; 4EE3A8C325B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "WebRTCModule+VideoTrackAdapter.m"; path = "RCTWebRTC/WebRTCModule+VideoTrackAdapter.m"; sourceTree = SOURCE_ROOT; }; 4EE3A8C425B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "WebRTCModule+VideoTrackAdapter.h"; path = "RCTWebRTC/WebRTCModule+VideoTrackAdapter.h"; sourceTree = SOURCE_ROOT; }; + A100000225B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "WebRTCModule+AudioTrackAdapter.m"; path = "RCTWebRTC/WebRTCModule+AudioTrackAdapter.m"; sourceTree = SOURCE_ROOT; }; + A100000325B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "WebRTCModule+AudioTrackAdapter.h"; path = "RCTWebRTC/WebRTCModule+AudioTrackAdapter.h"; sourceTree = SOURCE_ROOT; }; 4EE3A8C725B841DD00FAA24A /* ScreenCapturer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ScreenCapturer.h; path = RCTWebRTC/ScreenCapturer.h; sourceTree = SOURCE_ROOT; }; 4EE3A8C825B841DD00FAA24A /* SocketConnection.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = SocketConnection.m; path = RCTWebRTC/SocketConnection.m; sourceTree = SOURCE_ROOT; }; 4EE3A8C925B841DD00FAA24A /* CaptureController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CaptureController.m; path = RCTWebRTC/CaptureController.m; sourceTree = SOURCE_ROOT; }; @@ -142,6 +145,8 @@ 4EE3A8BF25B8416F00FAA24A /* WebRTCModule+RTCPeerConnection.m */, 4EE3A8C425B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.h */, 4EE3A8C325B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m */, + A100000325B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.h */, + A100000225B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m */, 4EE3A8B125B8414000FAA24A /* WebRTCModule.h */, 4EE3A8B025B8414000FAA24A /* WebRTCModule.m */, 4EE3A8CB25B841DD00FAA24A /* CaptureController.h */, @@ -256,6 +261,7 @@ buildActionMask = 2147483647; files = ( 4EE3A8C525B8417800FAA24A /* WebRTCModule+VideoTrackAdapter.m in Sources */, + A100000125B8417800FAA24A /* WebRTCModule+AudioTrackAdapter.m in Sources */, 4EE3A8BA25B8415900FAA24A /* WebRTCModule+RTCDataChannel.m in Sources */, 4EE3A8B625B8414A00FAA24A /* WebRTCModule+Permissions.m in Sources */, DEC96577264176C10052DB35 /* DataChannelWrapper.m in Sources */, diff --git a/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h b/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h new file mode 100644 index 000000000..4108d73e6 --- /dev/null +++ b/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.h @@ -0,0 +1,13 @@ + +#import +#import +#import "WebRTCModule.h" + +@interface RTCPeerConnection (AudioTrackAdapter) + +@property(nonatomic, strong) NSMutableDictionary *audioTrackAdapters; + +- (void)addAudioTrackAdapter:(RTCAudioTrack *)track; +- (void)removeAudioTrackAdapter:(RTCAudioTrack *)track; + +@end diff --git a/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m b/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m new file mode 100644 index 000000000..1dfd17726 --- /dev/null +++ b/ios/RCTWebRTC/WebRTCModule+AudioTrackAdapter.m @@ -0,0 +1,114 @@ + +#import +#import +#import +#import + +#import +#import +#import + +#import +#import + +#import "WebRTCModule+AudioTrackAdapter.h" +#import "WebRTCModule+RTCPeerConnection.h" +#import "WebRTCModule.h" + +/* Fires the W3C 'unmute' event on a remote audio track when the first + * decoded PCM buffer arrives via RTCAudioRenderer. + * + * IMPORTANT — only the initial muted → unmuted transition is detectable. + * Subsequent mute events (network stall mid-call) cannot be detected + * from the renderer: the iOS audio render path and WebRTC's NetEq + * synthesize silence / PLC frames whenever RTP stops, so + * renderPCMBuffer: keeps firing at a steady rate regardless of network + * state. For "remote participant muted their mic" UI, use the + * out-of-band participant state from your signaling layer — that is the + * correct source of truth, not this adapter. + */ +@interface FirstBufferUnmuteRenderer : NSObject + +@property(copy, nonatomic) NSNumber *peerConnectionId; +@property(copy, nonatomic) NSString *trackId; +@property(weak, nonatomic) WebRTCModule *module; + +- (instancetype)initWith:(NSNumber *)peerConnectionId + trackId:(NSString *)trackId + webRTCModule:(WebRTCModule *)module; + +@end + +@implementation FirstBufferUnmuteRenderer { + atomic_flag _fired; +} + +- (instancetype)initWith:(NSNumber *)peerConnectionId + trackId:(NSString *)trackId + webRTCModule:(WebRTCModule *)module { + self = [super init]; + if (self) { + self.peerConnectionId = peerConnectionId; + self.trackId = trackId; + self.module = module; + atomic_flag_clear(&_fired); + } + return self; +} + +- (void)renderPCMBuffer:(AVAudioPCMBuffer *)pcmBuffer { + if (atomic_flag_test_and_set(&_fired)) { + return; + } + [self.module sendEventWithName:kEventMediaStreamTrackMuteChanged + body:@{ + @"pcId" : self.peerConnectionId, + @"trackId" : self.trackId, + @"muted" : @NO + }]; + RCTLog(@"[AudioTrackAdapter] Unmute event for pc %@ track %@", self.peerConnectionId, self.trackId); +} + +@end + +@implementation RTCPeerConnection (AudioTrackAdapter) + +- (NSMutableDictionary *)audioTrackAdapters { + return objc_getAssociatedObject(self, _cmd); +} + +- (void)setAudioTrackAdapters:(NSMutableDictionary *)audioTrackAdapters { + objc_setAssociatedObject( + self, @selector(audioTrackAdapters), audioTrackAdapters, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (void)addAudioTrackAdapter:(RTCAudioTrack *)track { + NSString *trackId = track.trackId; + if ([self.audioTrackAdapters objectForKey:trackId] != nil) { + RCTLogWarn(@"[AudioTrackAdapter] Adapter already exists for track %@", trackId); + return; + } + + FirstBufferUnmuteRenderer *renderer = [[FirstBufferUnmuteRenderer alloc] initWith:self.reactTag + trackId:trackId + webRTCModule:self.webRTCModule]; + [self.audioTrackAdapters setObject:renderer forKey:trackId]; + [track addRenderer:renderer]; + + RCTLogTrace(@"[AudioTrackAdapter] Adapter created for track %@", trackId); +} + +- (void)removeAudioTrackAdapter:(RTCAudioTrack *)track { + NSString *trackId = track.trackId; + FirstBufferUnmuteRenderer *renderer = [self.audioTrackAdapters objectForKey:trackId]; + if (renderer == nil) { + RCTLogWarn(@"[AudioTrackAdapter] Adapter doesn't exist for track %@", trackId); + return; + } + + [track removeRenderer:renderer]; + [self.audioTrackAdapters removeObjectForKey:trackId]; + RCTLogTrace(@"[AudioTrackAdapter] Adapter removed for track %@", trackId); +} + +@end diff --git a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m index 38fd42279..3535c8ed6 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m +++ b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m @@ -18,6 +18,7 @@ #import #import "SerializeUtils.h" +#import "WebRTCModule+AudioTrackAdapter.h" #import "WebRTCModule+RTCDataChannel.h" #import "WebRTCModule+RTCPeerConnection.h" #import "WebRTCModule+VideoTrackAdapter.h" @@ -165,6 +166,7 @@ - (nullable RTCRtpTransceiver *)getTransceiverByPeerConnectionId:(nonnull NSNumb peerConnection.remoteTracks = [NSMutableDictionary new]; peerConnection.videoTrackAdapters = [NSMutableDictionary new]; peerConnection.videoDimensionDetectors = [NSMutableDictionary new]; + peerConnection.audioTrackAdapters = [NSMutableDictionary new]; peerConnection.webRTCModule = self; self.peerConnections[objectID] = peerConnection; @@ -395,12 +397,14 @@ - (nullable RTCRtpTransceiver *)getTransceiverByPeerConnectionId:(nonnull NSNumb return; } - // Remove video track adapters + // Remove track adapters for remote tracks for (NSString *key in peerConnection.remoteTracks.allKeys) { RTCMediaStreamTrack *track = peerConnection.remoteTracks[key]; if (track.kind == kRTCMediaStreamTrackKindVideo) { [peerConnection removeVideoTrackAdapter:(RTCVideoTrack *)track]; [peerConnection removeVideoDimensionDetector:(RTCVideoTrack *)track]; + } else if (track.kind == kRTCMediaStreamTrackKindAudio) { + [peerConnection removeAudioTrackAdapter:(RTCAudioTrack *)track]; } } @@ -981,6 +985,7 @@ - (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection [peerConnection addVideoDimensionDetector:videoTrack]; } else if (track.kind == kRTCMediaStreamTrackKindAudio) { RTCAudioTrack *audioTrack = (RTCAudioTrack *)track; + [peerConnection addAudioTrackAdapter:audioTrack]; WebRTCModuleOptions *options = [WebRTCModuleOptions sharedInstance]; audioTrack.source.volume = options.defaultTrackVolume; } diff --git a/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m b/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m index ccb8c31bf..f57f74aba 100644 --- a/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m +++ b/ios/RCTWebRTC/WebRTCModule+VideoTrackAdapter.m @@ -50,7 +50,8 @@ - (instancetype)initWith:(NSNumber *)peerConnectionId trackId:(NSString *)trackI _disposed = NO; _frameCount = 0; - _muted = NO; + // Per W3C spec, remote tracks MUST start muted. + _muted = YES; _timer = nil; } @@ -112,7 +113,13 @@ - (void)start { } - (void)renderFrame:(nullable RTCVideoFrame *)frame { - atomic_fetch_add(&_frameCount, 1); + // atomic_fetch_add returns the prior value; == 0 is the atomic "first + // frame" check — fire unmute immediately instead of waiting up to + // INITIAL_MUTE_DELAY for the periodic timer. + if (atomic_fetch_add(&_frameCount, 1) == 0 && self->_muted) { + self->_muted = NO; + [self emitMuteEvent:NO]; + } } - (void)setSize:(CGSize)size { diff --git a/macos/RCTWebRTC.xcodeproj/project.pbxproj b/macos/RCTWebRTC.xcodeproj/project.pbxproj index 9fca0a992..11c127345 100644 --- a/macos/RCTWebRTC.xcodeproj/project.pbxproj +++ b/macos/RCTWebRTC.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 0779C0B624D7C79B00E3B7C6 /* WebRTCModule+RTCPeerConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 35A2223F1CB493C00015FD5C /* WebRTCModule+RTCPeerConnection.m */; }; 0779C0B724D7C79B00E3B7C6 /* WebRTCModule+VideoTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = 0B56CFFF212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.m */; }; + A100000124D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = A100000324D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m */; }; 0779C0B824D7C79B00E3B7C6 /* WebRTCModule+RTCMediaStream.m in Sources */ = {isa = PBXBuildFile; fileRef = 35A2223D1CB493C00015FD5C /* WebRTCModule+RTCMediaStream.m */; }; 0779C0B924D7C79B00E3B7C6 /* VideoCaptureController.m in Sources */ = {isa = PBXBuildFile; fileRef = 0BDDA6DF20C18B6B00B38B45 /* VideoCaptureController.m */; }; 0779C0BA24D7C79B00E3B7C6 /* RTCMediaStreamTrack+React.m in Sources */ = {isa = PBXBuildFile; fileRef = 0BDDA6DC20C17E7C00B38B45 /* RTCMediaStreamTrack+React.m */; }; @@ -36,6 +37,8 @@ 0779C0C524D7C79B00E3B7C6 /* libRCTWebRTC-macos.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libRCTWebRTC-macos.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 0B56CFFE212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "WebRTCModule+VideoTrackAdapter.h"; sourceTree = ""; }; 0B56CFFF212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WebRTCModule+VideoTrackAdapter.m"; sourceTree = ""; }; + A100000224D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "WebRTCModule+AudioTrackAdapter.h"; sourceTree = ""; }; + A100000324D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WebRTCModule+AudioTrackAdapter.m"; sourceTree = ""; }; 0BC6C06B217F1D54005DCD37 /* WebRTCModule+Permissions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WebRTCModule+Permissions.m"; sourceTree = ""; }; 0BDDA6DC20C17E7C00B38B45 /* RTCMediaStreamTrack+React.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "RTCMediaStreamTrack+React.m"; sourceTree = ""; }; 0BDDA6DE20C17EA400B38B45 /* RTCMediaStreamTrack+React.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RTCMediaStreamTrack+React.h"; sourceTree = ""; }; @@ -103,6 +106,8 @@ 35A2223F1CB493C00015FD5C /* WebRTCModule+RTCPeerConnection.m */, 0B56CFFE212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.h */, 0B56CFFF212C12EF00213CEE /* WebRTCModule+VideoTrackAdapter.m */, + A100000224D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.h */, + A100000324D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m */, 35A222441CB493C00015FD5C /* WebRTCModule.h */, 35A222451CB493C00015FD5C /* WebRTCModule.m */, ); @@ -171,6 +176,7 @@ files = ( 0779C0B624D7C79B00E3B7C6 /* WebRTCModule+RTCPeerConnection.m in Sources */, 0779C0B724D7C79B00E3B7C6 /* WebRTCModule+VideoTrackAdapter.m in Sources */, + A100000124D7C79B00E3B7C6 /* WebRTCModule+AudioTrackAdapter.m in Sources */, 0779C0B824D7C79B00E3B7C6 /* WebRTCModule+RTCMediaStream.m in Sources */, 0779C0B924D7C79B00E3B7C6 /* VideoCaptureController.m in Sources */, 0779C0BA24D7C79B00E3B7C6 /* RTCMediaStreamTrack+React.m in Sources */, diff --git a/src/MediaStreamTrack.ts b/src/MediaStreamTrack.ts index 78cc17d12..9f84ddc80 100644 --- a/src/MediaStreamTrack.ts +++ b/src/MediaStreamTrack.ts @@ -61,7 +61,9 @@ export default class MediaStreamTrack extends EventTarget; _pendingTrackEvents: any[]; + // Native mute events that arrived before the matching JS track was + // constructed (can happen on fast/loopback connections). Drained when the + // remote track is created in setRemoteDescription. + _pendingMuteStates: Map; static generateCertificate( keygenAlgorithm: string | { @@ -173,6 +177,7 @@ export default class RTCPeerConnection extends EventTarget