crush-level-web/src/hooks/useAudioActivityDetection.ts

168 lines
4.6 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
interface AudioActivityConfig {
threshold?: number; // 音频阈值,默认 30
sampleRate?: number; // 采样率,默认 100ms
smoothingTimeConstant?: number; // 平滑常数,默认 0.8
fftSize?: number; // FFT 大小,默认 1024
}
interface UseAudioActivityDetectionReturn {
isDetecting: boolean;
isSpeaking: boolean;
audioLevel: number;
startDetection: (stream: MediaStream) => void;
stopDetection: () => void;
error: string | null;
}
export function useAudioActivityDetection(
config: AudioActivityConfig = {}
): UseAudioActivityDetectionReturn {
const {
threshold = 30,
sampleRate = 100,
smoothingTimeConstant = 0.8,
fftSize = 1024,
} = config;
const [isDetecting, setIsDetecting] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [audioLevel, setAudioLevel] = useState(0);
const [error, setError] = useState<string | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const microphoneRef = useRef<MediaStreamAudioSourceNode | null>(null);
const animationFrameRef = useRef<number | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// 音频分析函数
const analyzeAudio = useCallback(() => {
if (!analyserRef.current) return;
const analyser = analyserRef.current;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const checkAudioLevel = () => {
if (!analyser) return;
analyser.getByteFrequencyData(dataArray);
// 计算平均音量
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i];
}
const average = sum / bufferLength;
setAudioLevel(average);
// 判断是否在说话
const currentlySpeaking = average > threshold;
setIsSpeaking(currentlySpeaking);
// 调试日志
if (process.env.NODE_ENV === 'development') {
// console.log('Audio Level:', average, 'Speaking:', currentlySpeaking);
}
};
// 使用定时器而不是 requestAnimationFrame 以确保稳定的采样率
intervalRef.current = setInterval(checkAudioLevel, sampleRate);
}, [threshold, sampleRate]);
// 开始检测
const startDetection = useCallback(async (stream: MediaStream) => {
try {
setError(null);
// 创建音频上下文
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
const audioContext = audioContextRef.current;
// 确保音频上下文处于运行状态
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
// 创建分析器
const analyser = audioContext.createAnalyser();
analyser.smoothingTimeConstant = smoothingTimeConstant;
analyser.fftSize = fftSize;
// 创建音频源
const microphone = audioContext.createMediaStreamSource(stream);
microphone.connect(analyser);
analyserRef.current = analyser;
microphoneRef.current = microphone;
setIsDetecting(true);
analyzeAudio();
console.log('音频活动检测已启动');
} catch (err) {
console.error('启动音频检测失败:', err);
setError(err instanceof Error ? err.message : '启动音频检测失败');
}
}, [analyzeAudio, smoothingTimeConstant, fftSize]);
// 停止检测
const stopDetection = useCallback(() => {
// 清除定时器
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// 清除动画帧
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
// 断开音频连接
if (microphoneRef.current) {
microphoneRef.current.disconnect();
microphoneRef.current = null;
}
// 清理分析器
analyserRef.current = null;
// 关闭音频上下文
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close();
audioContextRef.current = null;
}
setIsDetecting(false);
setIsSpeaking(false);
setAudioLevel(0);
setError(null);
console.log('音频活动检测已停止');
}, []);
// 组件卸载时清理
useEffect(() => {
return () => {
stopDetection();
};
}, [stopDetection]);
return {
isDetecting,
isSpeaking,
audioLevel,
startDetection,
stopDetection,
error,
};
}