Hi Emanuel, welcome to the community!
What you’re describing, local state says muted, remote participants still hear audio, almost always turns out to be a second, unmuted track being published while the app’s UI is still tracking the old one. Your suspicion about the unplug/republish logic is very likely correct. Let me take your questions in order.
1. Can setMicrophoneEnabled(false) result in a muted state while audio is still transmitted?
Not by itself on a current SDK version. mute() sets enabled = false on the exact MediaStreamTrackattached to the RTCRtpSender (so the browser emits silence) and signals the server, which stops forwarding the track. If remote participants hear real audio, the sender must have a live, enabled track that your app’s state no longer describes — which points directly at the unpublish/republish flow:
- A freshly published track is always unmuted. If the user muted before (or during) your device-recovery swap, the new publication starts transmitting unless you explicitly re-apply mute after
publishTrack resolves.
- Conversely, if
mute() runs against the old track after you’ve unpublished it, the server update is silently dropped — you’ll see could not update mute status for unpublished track in the SDK’s error logs when this happens.
One more thing worth knowing: the SDK already handles a mic ended event internally (LocalParticipant.handleTrackEnded), it restarts the track on the default device if unmuted, or marks it muted if recovery fails. A custom app-level “unpublish + reacquire” handler runs in parallel with that built-in recovery, and the two racing each other is a classic way to end up with an orphaned live capture track (see #971).
The fix: for device changes/recovery, keep the same publication and use track.restartTrack(), track.setDeviceId(), or room.switchActiveDevice('audioinput', id) instead of unpublish + republish. These swap the underlying MediaStreamTrack in place, preserve mute state (enabled is synced to isMuted on the swapped-in track), and hold the same lock as mute/unmute. If you must republish, await the publish and then re-apply mute on the new publication.
2. Are there known race conditions around quickly toggling the mic?
There have been, and the fixes are version-specific, so: **which livekit-client version are you on?**The relevant ones:
- #980 (v1.15.10): stale track not stopped when
setMediaStreamTrack calls interleave (issue #971 — includes exactly the “enabled=false raced a restart, fresh track keeps capturing” case)
- #1620 (v2.15.10): all APIs that swap the underlying track (restart/replace/processor) now share one lock — the structural fix for mute-vs-restart races
- #1793 (v2.17.3): fixed an unmute→mute→unmute flap when a track restart happens during unmute
If you’re below 2.15.10 I’d upgrade before debugging anything else; ideally go to the latest 2.x.
Mute/unmute themselves are serialized behind a lock, but do make sure you await every setMicrophoneEnabled() call and don’t fire-and-forget overlapping toggles. One real edge case: calling setMicrophoneEnabled(false) while the initial publish is still in flight waits up to 10s for the pending publication — if publishing takes longer (slow getUserMedia, permission prompt), it times out, resolves undefined, and no mute is applied (the SDK logs waiting for pending publication promise timed out). So check the resolved value, and drive your mic icon from SDK events rather than from your own toggle flag.
3. How to verify the mic is actually not sending, beyond the muted flag?
Check the sender, not the publication state:
const pub = room.localParticipant.getTrackPublication(Track.Source.Microphone);
const track = pub?.track;
const senderTrack = track?.sender?.track;
console.log({
trackSid: pub?.trackSid,
pubMuted: pub?.isMuted,
mediaStreamTrackId: track?.mediaStreamTrack.id,
readyState: track?.mediaStreamTrack.readyState,
enabled: track?.mediaStreamTrack.enabled,
senderTrackId: senderTrack?.id,
senderEnabled: senderTrack?.enabled,
senderReadyState: senderTrack?.readyState,
});
The invariant when muted: senderTrack is either detached or has enabled === false. If you ever observe pubMuted: true with senderEnabled: true, you’ve caught the bug in the act — capture the log context around it. For ground truth on actual emitted audio, await track.getSenderStats()(audioLevel should sit at ~0 when muted) or track.getRTCStatsReport() and watch media-source / outbound-rtp audio stats. Also log pub.trackSid before and after your device-recovery path — if the sid changes, a republish happened and the new publication’s mute state is what counts.
4. What to log to debug this?
setLogLevel('debug') (exported from livekit-client), the mute path logs setTrackEnabled, setting audio track muted/unmuted, restart/reacquire steps, and the two telltale lines mentioned above (could not update mute status for unpublished track, waiting for pending publication promise timed out)
RoomEvent.LocalTrackPublished / LocalTrackUnpublished, log trackSid + mediaStreamTrack.id to spot unexpected republishes
RoomEvent.TrackMuted / TrackUnmuted on both the publishing client and a remote client — if the remote never receives TrackMuted, the mute signal was lost/dropped
RoomEvent.MediaDevicesError, RoomEvent.ActiveDeviceChanged, and TrackEvent.Ended / TrackEvent.Restarted on the mic track, to correlate the bug with your device-recovery path
room.localParticipant.lastMicrophoneError when things go sideways