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):
alice + bob in a call; io.lotus.call_state shows speaking=true for both.
kill the livekit-server process, restart it 2 s later.
Both clients full-reconnect and republish within ~2 s; audio flows again (measured RMS on the other side is unchanged).
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).
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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):
io.lotus.call_stateshowsspeaking=truefor both.killthelivekit-serverprocess, restart it 2 s later.speakingstaysfalseon 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).Reproduced 3/3 runs. Script:
repro-sfu-restart-speaking.mjs(attached; needs the cinnyscripts/dev-homeserver.sh callsstack; restart LiveKit from a shell when the script dropslk-restart.flag).Where to look:
speaking$inUserMediaViewModel.tsisobserveParticipantEvents(p, ParticipantEvent.IsSpeakingChanged)on whateverparticipant$currently holds. After a full reconnect livekit-client recreates remote participants with new SIDs and re-issues the local SID; ifparticipant$keeps the pre-reconnect object for one member, or the firstSpeakersChangedafter the join response is missed,isSpeakingnever flips again until something else (a mute round-trip) forces a fresh event. Candidate fix: onRoomEvent.Reconnectedre-derive fromparticipant.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.
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_statepipeline is wrong: it faithfully mirrorsparticipant.isSpeaking.Closing. If we ever want belt-and-braces, the place would be upstream livekit-client (seed
isSpeakingfromaudioLevelonReconnected), not the fork.