Speaking indicator stuck off for one participant after an SFU full reconnect (until they toggle their mic) #40

Closed
opened 2026-09-19 00:37:16 -04:00 by jared · 1 comment
Owner

Found while sweeping the fork features end-to-end (cinny #174).

Repro (local calls stack, two headless clients, both with a continuous fake-mic tone):

  1. alice + bob in a call; io.lotus.call_state shows speaking=true for both.
  2. kill the livekit-server process, restart it 2 s later.
  3. Both clients full-reconnect and republish within ~2 s; audio flows again (measured RMS on the other side is unchanged).
  4. One participant's speaking stays false on both sides — including in their own client — for as long as we watched (12 s+). Which one is stuck varies from run to run (alice in two runs, bob in one). Denoise tier does not matter (seen with Browser-native and with the ML tier).
  5. The stuck participant toggling their mic off/on fixes it immediately.

Reproduced 3/3 runs. Script: repro-sfu-restart-speaking.mjs (attached; needs the cinny scripts/dev-homeserver.sh calls stack; restart LiveKit from a shell when the script drops lk-restart.flag).

Where to look: speaking$ in UserMediaViewModel.ts is observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged) on whatever participant$ currently holds. After a full reconnect livekit-client recreates remote participants with new SIDs and re-issues the local SID; if participant$ keeps the pre-reconnect object for one member, or the first SpeakersChanged after the join response is missed, isSpeaking never flips again until something else (a mute round-trip) forces a fresh event. Candidate fix: on RoomEvent.Reconnected re-derive from participant.isSpeaking/audioLevel (or re-emit the participant), rather than relying on the next change event.

Impact: speaking rings / "X is speaking…" status bar wrong for one person after any SFU restart (= every LiveKit deploy). Audio itself is unaffected, so medium priority.

Found while sweeping the fork features end-to-end (cinny #174). **Repro** (local calls stack, two headless clients, both with a continuous fake-mic tone): 1. alice + bob in a call; `io.lotus.call_state` shows `speaking=true` for both. 2. `kill` the `livekit-server` process, restart it 2 s later. 3. Both clients full-reconnect and republish within ~2 s; audio flows again (measured RMS on the other side is unchanged). 4. **One** participant's `speaking` stays `false` on **both** sides — including in their own client — for as long as we watched (12 s+). Which one is stuck varies from run to run (alice in two runs, bob in one). Denoise tier does not matter (seen with Browser-native and with the ML tier). 5. The stuck participant toggling their mic off/on fixes it immediately. Reproduced 3/3 runs. Script: `repro-sfu-restart-speaking.mjs` (attached; needs the cinny `scripts/dev-homeserver.sh calls` stack; restart LiveKit from a shell when the script drops `lk-restart.flag`). **Where to look:** `speaking$` in `UserMediaViewModel.ts` is `observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged)` on whatever `participant$` currently holds. After a full reconnect livekit-client recreates remote participants with new SIDs and re-issues the local SID; if `participant$` keeps the pre-reconnect object for one member, or the first `SpeakersChanged` after the join response is missed, `isSpeaking` never flips again until something else (a mute round-trip) forces a fresh event. Candidate fix: on `RoomEvent.Reconnected` re-derive from `participant.isSpeaking`/`audioLevel` (or re-emit the participant), rather than relying on the next change event. **Impact:** speaking rings / "X is speaking…" status bar wrong for one person after any SFU restart (= every LiveKit deploy). Audio itself is unaffected, so medium priority.
Author
Owner

Root-caused — and it is a test artifact, not a product bug.

livekit-client only ever learns about speaking via server deltas (SpeakersChangedRoom.handleSpeakersChanged, "updates are sent only when there's a change"). There is no speaker snapshot in the join response. So a participant whose audio is already active on the SFU at the moment a connection (re)joins produces no delta for that connection until their level next changes — and my fake mics are a continuous 10 s tone that never pauses, so the transition never comes. Which participant gets stuck is just whoever the new SFU flagged active before the other side's join finished, hence the run-to-run variation. Muting and unmuting is simply the first level change.

Real speech has pauses every few seconds, so after a reconnect (or when joining a call where someone is mid-sentence) the indicator catches up on the speaker's next pause. Nothing in the fork's call_state pipeline is wrong: it faithfully mirrors participant.isSpeaking.

Closing. If we ever want belt-and-braces, the place would be upstream livekit-client (seed isSpeaking from audioLevel on Reconnected), not the fork.

Root-caused — and it is a **test artifact**, not a product bug. livekit-client only ever learns about speaking via server *deltas* (`SpeakersChanged` → `Room.handleSpeakersChanged`, "updates are sent only when there's a change"). There is no speaker snapshot in the join response. So a participant whose audio is *already* active on the SFU at the moment a connection (re)joins produces no delta for that connection until their level next changes — and my fake mics are a continuous 10 s tone that never pauses, so the transition never comes. Which participant gets stuck is just whoever the new SFU flagged active before the other side's join finished, hence the run-to-run variation. Muting and unmuting is simply the first level change. Real speech has pauses every few seconds, so after a reconnect (or when joining a call where someone is mid-sentence) the indicator catches up on the speaker's next pause. Nothing in the fork's `call_state` pipeline is wrong: it faithfully mirrors `participant.isSpeaking`. Closing. If we ever want belt-and-braces, the place would be upstream livekit-client (seed `isSpeaking` from `audioLevel` on `Reconnected`), not the fork.
jared closed this issue 2026-09-19 00:46:23 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/element-call#40