196 lines
7.6 KiB
TypeScript
196 lines
7.6 KiB
TypeScript
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 intervalRef = useRef<NodeJS.Timeout | 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);
|
||
}
|
||
}
|
||
|
||
// Логируем каждые 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 };
|
||
}
|