Summary
In 144.1.2, a remote MediaStreamTrack is created with muted === false and receives an unmute event unconditionally at the end of setRemoteDescription, that is at negotiation time rather than when the first RTP packets arrive.
Per the WebRTC spec, a remote track is created muted and becomes unmuted when media actually starts arriving on it. As implemented, the muted property and the mute / unmute events carry no information about whether media is flowing, so applications cannot use them to know when the call is actually audible.
We may be missing a reason for the current behaviour; if it is deliberate, saying so in the docs would already help, since the property currently reads as spec-compliant.
On our 1:1 audio calls between two iPhones, unmute fires about 12 s before the first audio is really received on the caller side (see #100 for why the gap is that large in our case; the ordering issue is independent of it).
Related but, as far as we can tell, distinct: #66 is about local mic mute/unmute flapping on iOS, not about when a remote track is announced as unmuted.
Where
src/RTCPeerConnection.ts, at the end of the pending-track-events loop in setRemoteDescription:
// src/RTCPeerConnection.ts:400-402
this.dispatchEvent(new RTCTrackEvent('track', eventData));
streams.forEach(stream => {
stream.dispatchEvent(new MediaStreamTrackEvent('addtrack', { track }));
});
// Dispatch an unmute event for the track.
track._setMutedInternal(false);
And the track is already constructed unmuted:
// src/MediaStreamTrack.ts:60
this._muted = false;
so track.muted is false from the moment the track event fires, before any media could have arrived.
The native-driven path already exists and is wired up: the native side emits mediaStreamTrackMuteChanged, and the handler applies it as expected.
// src/RTCPeerConnection.ts:798-809
addListener(this, 'mediaStreamTrackMuteChanged', (ev: any) => {
if (ev.pcId !== this._pcId) { return; }
const [ track ] = this.getReceivers().map(r => r.track).filter(t => t?.id === ev.trackId);
if (track) {
track._setMutedInternal(ev.muted);
}
});
The unconditional _setMutedInternal(false) in setRemoteDescription pre-empts it: by the time any native mute state could be reported, the track has already been announced as unmuted.
(The same code appears in the built output, lib/module/RTCPeerConnection.js:298. This section looks inherited from upstream react-native-webrtc rather than fork-specific, so feel free to route it accordingly.)
Impact
unmute is the standard signal an application uses to know that a remote track has started producing media, for instance to stop showing "connecting…" and start showing "connected". Here it fires at negotiation, so:
- any UI driven by
unmute lights up long before there is audio;
track.muted never becomes true, so it can't be polled as a fallback either;
- a track that genuinely stops receiving media does not reliably surface as
mute from this path.
We had to fall back to polling getStats() every 250 ms, looking for an inbound-rtp audio entry with bytesReceived > 0. That works (it detects the right moment within 0.1 to 0.3 s on both devices, with no measurable cost on the JS thread), but it is a fair amount of application code to reimplement something the spec already defines.
A possible direction
One option: remove the unconditional track._setMutedInternal(false) from setRemoteDescription, construct remote tracks with _muted = true, and let the existing mediaStreamTrackMuteChanged listener drive the transitions from the native side. That assumes the native ADM / receiver actually reports the first-media transition on both platforms, which is the part worth checking first, and we have not verified it.
If dropping the unconditional unmute would break existing apps that rely on it, it could be gated behind a flag for a release. As it stands we do not see how the current behaviour lines up with the spec's meaning of muted, but we may be missing context.
Environment
@livekit/react-native-webrtc : 144.1.2
react-native : 0.83.10
react : 19.2.0
expo : ~55.0.28
Devices : iPhone 17 Pro Max and iPhone 12
iOS : 26.6.1 on both
Audio-only 1:1 call, one audio track per peer.
Summary
In 144.1.2, a remote
MediaStreamTrackis created withmuted === falseand receives anunmuteevent unconditionally at the end ofsetRemoteDescription, that is at negotiation time rather than when the first RTP packets arrive.Per the WebRTC spec, a remote track is created muted and becomes unmuted when media actually starts arriving on it. As implemented, the
mutedproperty and themute/unmuteevents carry no information about whether media is flowing, so applications cannot use them to know when the call is actually audible.We may be missing a reason for the current behaviour; if it is deliberate, saying so in the docs would already help, since the property currently reads as spec-compliant.
On our 1:1 audio calls between two iPhones,
unmutefires about 12 s before the first audio is really received on the caller side (see #100 for why the gap is that large in our case; the ordering issue is independent of it).Related but, as far as we can tell, distinct: #66 is about local mic mute/unmute flapping on iOS, not about when a remote track is announced as unmuted.
Where
src/RTCPeerConnection.ts, at the end of the pending-track-events loop insetRemoteDescription:And the track is already constructed unmuted:
so
track.mutedisfalsefrom the moment thetrackevent fires, before any media could have arrived.The native-driven path already exists and is wired up: the native side emits
mediaStreamTrackMuteChanged, and the handler applies it as expected.The unconditional
_setMutedInternal(false)insetRemoteDescriptionpre-empts it: by the time any native mute state could be reported, the track has already been announced as unmuted.(The same code appears in the built output,
lib/module/RTCPeerConnection.js:298. This section looks inherited from upstreamreact-native-webrtcrather than fork-specific, so feel free to route it accordingly.)Impact
unmuteis the standard signal an application uses to know that a remote track has started producing media, for instance to stop showing "connecting…" and start showing "connected". Here it fires at negotiation, so:unmutelights up long before there is audio;track.mutednever becomestrue, so it can't be polled as a fallback either;mutefrom this path.We had to fall back to polling
getStats()every 250 ms, looking for aninbound-rtpaudio entry withbytesReceived > 0. That works (it detects the right moment within 0.1 to 0.3 s on both devices, with no measurable cost on the JS thread), but it is a fair amount of application code to reimplement something the spec already defines.A possible direction
One option: remove the unconditional
track._setMutedInternal(false)fromsetRemoteDescription, construct remote tracks with_muted = true, and let the existingmediaStreamTrackMuteChangedlistener drive the transitions from the native side. That assumes the native ADM / receiver actually reports the first-media transition on both platforms, which is the part worth checking first, and we have not verified it.If dropping the unconditional unmute would break existing apps that rely on it, it could be gated behind a flag for a release. As it stands we do not see how the current behaviour lines up with the spec's meaning of
muted, but we may be missing context.Environment
Audio-only 1:1 call, one audio track per peer.