feat(phase6): video grid, noise suppression, whisper system

- New VideoGrid component: live camera feeds (local + remote) in VoicePanel
- Camera toggle in VoiceControls: enable/disable video per participant
- Noise suppression toggle in VoiceControls + voice store
- VOICE_WHISPER WS event: backend routes whisper to target user only
- Whisper UI: per-participant whisper button, notification toasts with dismiss
- Fix circular import between voice.ts and ws.ts (use lazy accessor)
This commit is contained in:
2026-06-30 12:21:53 -04:00
parent 3c063d4f56
commit 775bd953b0
8 changed files with 307 additions and 28 deletions
+62
View File
@@ -11,6 +11,7 @@ import {
} from 'livekit-client';
import { api } from '../lib/api.ts';
import { useWebSocketStore } from './ws.ts';
import { useAuthStore } from './auth.ts';
export interface VoiceParticipant {
identity: string;
@@ -19,6 +20,13 @@ export interface VoiceParticipant {
isSpeaking: boolean;
}
export interface WhisperNotification {
fromUserId: string;
fromUsername: string;
message: string;
timestamp: number;
}
interface VoiceTokenResponse {
token: string;
livekit_url: string;
@@ -32,16 +40,22 @@ interface VoiceState {
isDeafened: boolean;
isVideoOn: boolean;
isScreenSharing: boolean;
noiseSuppression: boolean;
participants: VoiceParticipant[];
error: string | null;
_room: Room | null;
isJoining: boolean;
whispers: WhisperNotification[];
joinVoice: (channelId: string, channelName: string) => Promise<void>;
leaveVoice: () => Promise<void>;
toggleMute: () => void;
toggleDeafen: () => void;
toggleVideo: () => void;
toggleScreenShare: () => void;
toggleNoiseSuppression: () => void;
_addWhisper: (w: WhisperNotification) => void;
whisperTo: (targetUserId: string, targetUsername: string, message?: string) => void;
dismissWhisper: (timestamp: number) => void;
}
function participantToVoice(p: Participant): VoiceParticipant {
@@ -62,10 +76,12 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
isDeafened: false,
isVideoOn: false,
isScreenSharing: false,
noiseSuppression: true,
participants: [],
error: null,
_room: null,
isJoining: false,
whispers: [],
joinVoice: async (channelId, channelName) => {
if (get().isJoining) {
@@ -290,4 +306,50 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
await room.localParticipant.setScreenShareEnabled(newState);
set({ isScreenSharing: newState });
},
toggleNoiseSuppression: async () => {
const room = get()._room;
if (!room) return;
const newState = !get().noiseSuppression;
set({ noiseSuppression: newState });
// Apply noise suppression + echo cancellation to local audio track
try {
const audioTrack = room.localParticipant.getTrackPublication(Track.Source.Microphone);
if (audioTrack?.track?.mediaStreamTrack) {
await audioTrack.track.mediaStreamTrack.applyConstraints({
echoCancellation: newState,
noiseSuppression: newState,
autoGainControl: newState,
} as MediaTrackConstraints);
}
} catch (err) {
console.warn('[voice] failed to update audio processing:', err);
}
},
_addWhisper: (w) => {
set((state) => ({ whispers: [...state.whispers, w] }));
},
whisperTo: (targetUserId, targetUsername, message) => {
const wsSend = useWebSocketStore.getState().send;
const me = useAuthStore.getState().user;
wsSend({
type: 'VOICE_WHISPER',
payload: {
target_user_id: targetUserId,
target_username: targetUsername,
from_username: me?.username || 'unknown',
message: message || '',
},
});
},
dismissWhisper: (timestamp) => {
set((state) => ({
whispers: state.whispers.filter((w) => w.timestamp !== timestamp),
}));
},
}));