Skip to content

Remote track unmute fires when setRemoteDescription returns, not when media starts flowing (and remote tracks are never muted) #101

Description

@lau-sam

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions