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(null); const analyserRef = useRef(null); const intervalRef = useRef(null); const lastSpeakingTimeRef = useRef(0); const speakingTimeoutRef = useRef(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); } } // Логируем каждые 180 вызовов (~3 секунды при частоте 60 Hz) - снижено для меньшего шума frameCount++; if (frameCount % 180 === 0) { console.log( `[VoiceActivity] Level: ${audioLevel.toFixed( 1 )}% | RMS: ${rms.toFixed( 4 )} | Threshold: ${threshold}% | Speaking: ${ isActive ? "🟢 YES" : "⚪ NO" }` ); } }; // Запускаем проверку с интервалом ~16ms (приблизительно 60 FPS) // setInterval работает стабильно даже когда окно неактивно intervalRef.current = setInterval(checkVoiceActivity, 16); 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 (intervalRef.current !== null) { clearInterval(intervalRef.current); intervalRef.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 }; }