Update environment configuration for production, refactor WebRTC components, and enhance chat functionality. Replace deprecated SessionUsersPanel with SessionPage, integrate chat history loading, and improve audio/video toggle handling. Remove unused SessionUsersPanel2 component and update related socket event handling in the server.

This commit is contained in:
2025-10-27 16:49:52 +05:00
parent 95f7b90d38
commit 2378ed1ff4
20 changed files with 936 additions and 304 deletions
+37
View File
@@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import type { ChatMessage } from "../lib/webrtc";
interface ChatHistoryResponse {
success: boolean;
messages: ChatMessage[];
count: number;
error?: string;
}
export const useChatHistory = (sessionId: string | undefined, enabled = true) => {
return useQuery({
queryKey: ["chat-history", sessionId],
queryFn: async () => {
if (!sessionId) {
throw new Error("Session ID is required");
}
const response = await api
.get(`sessions/${sessionId}/messages`)
.json<ChatHistoryResponse>();
if (!response.success) {
throw new Error(response.error || "Failed to load chat history");
}
return response.messages;
},
enabled: enabled && !!sessionId,
staleTime: 1000 * 60 * 5, // 5 минут - история считается актуальной
gcTime: 1000 * 60 * 30, // 30 минут в кэше
refetchOnWindowFocus: false, // Не перезагружать при фокусе
refetchOnReconnect: false, // Не перезагружать при реконнекте
});
};
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useState, useRef } from "react";
interface UseVoiceActivityOptions {
threshold?: number; // Порог громкости (0-100)
smoothingTimeConstant?: number; // Сглаживание (0-1)
fftSize?: number; // Размер FFT для анализа
debounceTime?: number; // Время задержки выключения индикатора (ms)
}
/**
* Хук для определения голосовой активности в MediaStream
* @param stream - MediaStream для анализа
* @param options - Опции для настройки детекции
* @returns объект с isSpeaking и audioLevel
*/
export function useVoiceActivity(
stream: MediaStream | null | undefined,
options: UseVoiceActivityOptions = {}
): { isSpeaking: boolean; audioLevel: number } {
const {
threshold = 6, // Низкий порог для непрерывной речи (ловит тихие паузы между словами)
smoothingTimeConstant = 0.8, // Высокое сглаживание для стабильности
fftSize = 2048, // Больший размер для лучшей точности голоса
debounceTime = 1000, // 1 секунда задержки выключения
} = options;
const [isSpeaking, setIsSpeaking] = useState(false);
const [audioLevel, setAudioLevel] = useState(0);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const animationFrameRef = useRef<number | null>(null);
const lastSpeakingTimeRef = useRef<number>(0);
const speakingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!stream) {
setIsSpeaking(false);
setAudioLevel(0);
return;
}
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
setIsSpeaking(false);
setAudioLevel(0);
return;
}
// Проверяем, что аудио трек активен
const audioTrack = audioTracks[0];
if (!audioTrack.enabled) {
setIsSpeaking(false);
setAudioLevel(0);
return;
}
try {
// Создаем AudioContext
const audioContext = new AudioContext();
audioContextRef.current = audioContext;
// Создаем источник из MediaStream
const source = audioContext.createMediaStreamSource(stream);
// Создаем анализатор
const analyser = audioContext.createAnalyser();
analyser.fftSize = fftSize;
analyser.smoothingTimeConstant = smoothingTimeConstant;
analyserRef.current = analyser;
// Подключаем источник к анализатору
source.connect(analyser);
// Буфер для данных
const dataArray = new Uint8Array(analyser.frequencyBinCount);
// Счетчик для логирования (не логируем каждый кадр)
let frameCount = 0;
// Функция для проверки активности
const checkVoiceActivity = () => {
if (!analyserRef.current) return;
// Получаем waveform (временные данные)
analyser.getByteTimeDomainData(dataArray);
// Вычисляем RMS (Root Mean Square) - эффективную громкость
// dataArray содержит значения от 0 до 255, где 128 = тишина
let sumSquares = 0;
for (let i = 0; i < dataArray.length; i++) {
const normalized = (dataArray[i] - 128) / 128; // Нормализуем к диапазону -1 до 1
sumSquares += normalized * normalized;
}
const rms = Math.sqrt(sumSquares / dataArray.length);
// Преобразуем RMS в проценты (калибровка под Chrome)
// RMS обычно в диапазоне 0.0-0.3 (тишина-громкая речь)
// Используем нелинейную кривую для лучшего соответствия Chrome
const audioLevel = Math.min(100, Math.pow(rms * 100, 1.2));
// Обновляем уровень громкости постоянно
setAudioLevel(audioLevel);
// Проверяем, превышен ли порог
const isActive = audioLevel > threshold;
if (isActive) {
// Если человек говорит сейчас - обновляем время и включаем индикатор
lastSpeakingTimeRef.current = Date.now();
setIsSpeaking(true);
// Отменяем предыдущий таймер выключения (если был)
if (speakingTimeoutRef.current) {
clearTimeout(speakingTimeoutRef.current);
speakingTimeoutRef.current = null;
}
} else {
// Если сейчас тишина, проверяем прошло ли debounceTime с последней активности
const timeSinceLastSpeaking =
Date.now() - lastSpeakingTimeRef.current;
if (timeSinceLastSpeaking >= debounceTime) {
// Прошло достаточно времени - выключаем индикатор
setIsSpeaking(false);
} else if (!speakingTimeoutRef.current) {
// Ставим таймер на выключение через оставшееся время
const remainingTime = debounceTime - timeSinceLastSpeaking;
speakingTimeoutRef.current = setTimeout(() => {
setIsSpeaking(false);
speakingTimeoutRef.current = null;
}, remainingTime);
}
}
// Логируем каждые 30 кадров (~500ms при 60fps)
frameCount++;
if (frameCount % 30 === 0) {
console.log(
`[VoiceActivity] Level: ${audioLevel.toFixed(
1
)}% | RMS: ${rms.toFixed(
4
)} | Threshold: ${threshold}% | Speaking: ${
isActive ? "🟢 YES" : "⚪ NO"
}`
);
}
// Запланировать следующую проверку
animationFrameRef.current = requestAnimationFrame(checkVoiceActivity);
};
// Запускаем проверку
checkVoiceActivity();
console.log(
`[useVoiceActivity] Started voice activity detection - Threshold: ${threshold}, FFT: ${fftSize}, Smoothing: ${smoothingTimeConstant}, Debounce: ${debounceTime}ms`
);
} catch (error) {
console.error(
"[useVoiceActivity] Error setting up voice detection:",
error
);
setIsSpeaking(false);
setAudioLevel(0);
}
// Cleanup
return () => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (speakingTimeoutRef.current !== null) {
clearTimeout(speakingTimeoutRef.current);
speakingTimeoutRef.current = null;
}
if (analyserRef.current) {
analyserRef.current.disconnect();
analyserRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
setIsSpeaking(false);
setAudioLevel(0);
console.log("[useVoiceActivity] Cleaned up voice activity detection");
};
}, [stream, threshold, smoothingTimeConstant, fftSize, debounceTime]);
return { isSpeaking, audioLevel };
}
+30 -6
View File
@@ -98,15 +98,33 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => {
onParticipantLeft: (participantId) => {
setParticipants((prev) => prev.filter((p) => p.id !== participantId));
},
onParticipantAudioToggle: (participantId, isEnabled) => {
console.log(`[useWebRTC] Audio toggle for ${participantId}: ${isEnabled}`);
setParticipants((prev) =>
prev.map((p) =>
p.id === participantId ? { ...p, isMuted: !isEnabled } : p
)
);
},
onParticipantVideoToggle: (participantId, isEnabled) => {
console.log(`[useWebRTC] Video toggle for ${participantId}: ${isEnabled}`);
setParticipants((prev) =>
prev.map((p) =>
p.id === participantId ? { ...p, isVideoOff: !isEnabled } : p
)
);
},
onParticipantSpeakingChange: (participantId, isSpeaking) => {
setParticipants((prev) =>
prev.map((p) =>
p.id === participantId ? { ...p, isSpeaking } : p
)
);
},
onChatMessage: (message) => {
console.log("[useWebRTC] onChatMessage called:", message);
setChatMessages((prev) => [...prev, message]);
},
onDataChannelOpen: () => {
// DataChannel opened
},
onDataChannelClose: () => {
// DataChannel closed
},
onError: (error) => {
console.error("[useWebRTC] Error:", error);
},
@@ -202,6 +220,11 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => {
setParticipants([]);
};
const updateSpeakingState = (isSpeaking: boolean) => {
if (!webrtcServiceInstance) return;
webrtcServiceInstance.updateSpeakingState(isSpeaking);
};
return {
localStream,
participants,
@@ -214,6 +237,7 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => {
toggleAudio,
toggleVideo,
sendMessage,
updateSpeakingState,
joinRoom,
leaveRoom,
};