Files
stream.graff.tech-new/client/src/hooks/useVoiceActivity.ts
T

196 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}