fix: wire LiveKit speaker detection to voice activation ring

- handleActiveSpeakersChanged now calls setSpeakers() with proper
  channel_id and speaker list (was incorrectly calling setLocalSpeaking
  in a loop for all users)
- setSpeakers() now updates ALL users including local (LiveKit is
  sole authority for speaking detection, no local VAD)
- Make threshold_mode optional in VoiceSpeakersPayload (LiveKit
  handles mixing internally)
- Update voice store test for new behavior
This commit is contained in:
jevb
2026-03-20 06:33:57 +01:00
parent 682e6cbae9
commit 7ba6287c35
4 changed files with 19 additions and 31 deletions
+10 -15
View File
@@ -21,8 +21,8 @@ import {
voiceStore,
setLocalMuted,
setLocalDeafened,
setLocalSpeaking,
setLocalCamera,
setSpeakers,
} from "@stores/voice.store";
import { loadPref, savePref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
@@ -203,26 +203,21 @@ function handleTrackUnsubscribed(
}
function handleActiveSpeakersChanged(speakers: Participant[]): void {
const speakerIds = new Set<number>();
if (currentChannelId === null) return;
const speakerUserIds: number[] = [];
for (const speaker of speakers) {
const userId = parseUserId(speaker.identity);
if (userId > 0) {
speakerIds.add(userId);
speakerUserIds.push(userId);
}
}
// Update speaking state in voice store for all users in the channel
const state = voiceStore.getState();
const channelId = state.currentChannelId;
if (channelId === null) return;
const channelUsers = state.voiceUsers.get(channelId);
if (!channelUsers) return;
for (const [userId] of channelUsers) {
const isSpeaking = speakerIds.has(userId);
setLocalSpeaking(isSpeaking);
}
// Use setSpeakers to update all users' speaking state at once
setSpeakers({
channel_id: currentChannelId,
speakers: speakerUserIds,
});
}
function handleDisconnected(reason?: DisconnectReason): void {
+1 -1
View File
@@ -269,7 +269,7 @@ export interface VoiceConfigPayload {
export interface VoiceSpeakersPayload {
readonly channel_id: number;
readonly speakers: readonly number[];
readonly threshold_mode: string;
readonly threshold_mode?: string;
}
export interface VoiceTokenPayload {
+3 -10
View File
@@ -250,25 +250,18 @@ export function setVoiceConfig(payload: VoiceConfigPayload): void {
});
}
/** Update speaking state for users from a voice_speakers event.
* Skips the local user — their speaking state is driven by local VAD
* (lower latency, same threshold). Prevents flicker from two sources
* disagreeing on the same field. */
/** Update speaking state for users from a voice_speakers event or
* LiveKit's ActiveSpeakersChanged. Updates ALL users including local
* (LiveKit is now the sole authority for speaking detection). */
export function setSpeakers(payload: VoiceSpeakersPayload): void {
voiceStore.setState((prev) => {
const existingChannel = prev.voiceUsers.get(payload.channel_id);
if (!existingChannel) return prev;
const currentUserId = authStore.getState().user?.id ?? 0;
const speakerSet = new Set(payload.speakers);
const nextUsers = new Map<number, VoiceUser>();
for (const [userId, user] of existingChannel) {
// Skip local user — local VAD is the sole authority for our own indicator
if (userId === currentUserId) {
nextUsers.set(userId, user);
continue;
}
const isSpeaking = speakerSet.has(userId);
if (user.speaking !== isSpeaking) {
nextUsers.set(userId, { ...user, speaking: isSpeaking });
@@ -346,14 +346,14 @@ describe("voice store", () => {
}));
});
it("does NOT overwrite local user's speaking state", () => {
// Local VAD says we're speaking
setLocalSpeaking(true);
it("updates local user's speaking state from LiveKit", () => {
// LiveKit says we're speaking
setSpeakers({ channel_id: 10, speakers: [1, 2], threshold_mode: "forwarding" });
expect(voiceStore.getState().voiceUsers.get(10)?.get(1)?.speaking).toBe(true);
// Server says we're NOT speaking — local user should be unchanged
// LiveKit says we're NOT speaking — should update
setSpeakers({ channel_id: 10, speakers: [2], threshold_mode: "forwarding" });
expect(voiceStore.getState().voiceUsers.get(10)?.get(1)?.speaking).toBe(true);
expect(voiceStore.getState().voiceUsers.get(10)?.get(1)?.speaking).toBe(false);
});
it("updates remote users' speaking state from server", () => {