240 lines
8.3 KiB
TypeScript
240 lines
8.3 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react";
|
||
|
||
interface VoiceAssistantProps {
|
||
greeting: string;
|
||
}
|
||
|
||
// 性能优化配置
|
||
const ANALYSER_FFT_SIZE = 128; // 降低FFT大小提升性能
|
||
const VOLUME_SENSITIVITY = 1.5;
|
||
const SMOOTHING_FACTOR = 0.7; // 增加平滑系数减少突变
|
||
const BAR_COUNT = 12; // 固定柱状图数量
|
||
|
||
const VoiceAssistant = ({ greeting }: VoiceAssistantProps) => {
|
||
// 状态管理
|
||
const [isListening, setIsListening] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// DOM元素引用
|
||
const barsRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 音频处理相关引用
|
||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||
const audioContextRef = useRef<AudioContext | null>(null);
|
||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||
const animationFrameRef = useRef<number | null>(null);
|
||
|
||
// 重用数据数组减少内存分配
|
||
const dataArrayRef = useRef<Uint8Array | null>(null);
|
||
const lastValuesRef = useRef<number[]>(new Array(BAR_COUNT).fill(10));
|
||
|
||
// 初始化音频处理(使用useCallback避免重复创建)
|
||
const initAudioPipeline = useCallback(async () => {
|
||
try {
|
||
mediaStreamRef.current = await navigator.mediaDevices.getUserMedia({
|
||
audio: {
|
||
noiseSuppression: true,
|
||
echoCancellation: true,
|
||
autoGainControl: false,
|
||
},
|
||
});
|
||
|
||
const AudioContextClass =
|
||
window.AudioContext || (window as any).webkitAudioContext;
|
||
audioContextRef.current = new AudioContextClass({
|
||
latencyHint: "balanced", // 平衡延迟和性能
|
||
});
|
||
|
||
analyserRef.current = audioContextRef.current.createAnalyser();
|
||
analyserRef.current.fftSize = ANALYSER_FFT_SIZE;
|
||
analyserRef.current.smoothingTimeConstant = SMOOTHING_FACTOR;
|
||
|
||
// 初始化数据数组
|
||
dataArrayRef.current = new Uint8Array(
|
||
analyserRef.current.frequencyBinCount
|
||
);
|
||
|
||
const source = audioContextRef.current.createMediaStreamSource(
|
||
mediaStreamRef.current
|
||
);
|
||
source.connect(analyserRef.current);
|
||
|
||
startVisualization();
|
||
} catch (err) {
|
||
console.error("音频初始化失败:", err);
|
||
setIsListening(false);
|
||
}
|
||
}, []);
|
||
|
||
// 优化后的可视化逻辑(直接操作DOM)
|
||
const startVisualization = useCallback(() => {
|
||
if (!analyserRef.current || !dataArrayRef.current) {
|
||
console.error('Audio analyzer not initialized');
|
||
return;
|
||
}
|
||
|
||
// 获取频率数据缓冲区长度
|
||
const bufferLength = analyserRef.current.frequencyBinCount;
|
||
|
||
// 初始化时创建数据数组(安全校验)
|
||
if (!dataArrayRef.current || dataArrayRef.current.length !== bufferLength) {
|
||
dataArrayRef.current = new Uint8Array(bufferLength);
|
||
}
|
||
|
||
// 定义动画帧回调
|
||
const updateBars = () => {
|
||
// 1. 获取最新频率数据
|
||
analyserRef.current!.getByteFrequencyData(dataArrayRef.current!);
|
||
|
||
// 2. 性能优化:批量DOM操作
|
||
const bars = barsRef.current?.children;
|
||
if (!bars) return;
|
||
|
||
// 3. 使用现代循环代替Array.from提升性能
|
||
for (let i = 0; i < bars.length; i++) {
|
||
const bar = bars[i] as HTMLElement;
|
||
|
||
// 4. 优化数据采样策略(前1/2频谱)
|
||
const dataIndex = Math.floor((i / BAR_COUNT) * (bufferLength / 2));
|
||
const rawValue = (dataArrayRef.current![dataIndex] / 255) * 100 * VOLUME_SENSITIVITY;
|
||
|
||
// 5. 应用指数平滑滤波
|
||
const smoothValue = Math.min(100, Math.max(10,
|
||
rawValue * 0.6 + lastValuesRef.current[i] * 0.4
|
||
));
|
||
lastValuesRef.current[i] = smoothValue;
|
||
|
||
// 6. 复合样式更新(减少重排)
|
||
bar.style.cssText = `
|
||
height: ${smoothValue}%;
|
||
transform: scaleY(${0.8 + (smoothValue / 100) * 0.6});
|
||
animation-delay: ${i * 0.1}s;
|
||
`;
|
||
}
|
||
|
||
// 7. 使用绑定this的requestAnimationFrame
|
||
animationFrameRef.current = requestAnimationFrame(updateBars.bind(this));
|
||
};
|
||
|
||
// 8. 启动动画前取消已有帧
|
||
if (animationFrameRef.current) {
|
||
cancelAnimationFrame(animationFrameRef.current);
|
||
}
|
||
animationFrameRef.current = requestAnimationFrame(updateBars);
|
||
|
||
// 9. 返回清理函数
|
||
return () => {
|
||
if (animationFrameRef.current) {
|
||
cancelAnimationFrame(animationFrameRef.current);
|
||
}
|
||
};
|
||
}, []); // 10. 移除不必要的依赖项
|
||
|
||
// 切换监听状态
|
||
const toggleListening = useCallback(async () => {
|
||
if (isListening) {
|
||
mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||
audioContextRef.current?.close();
|
||
if (animationFrameRef.current) {
|
||
cancelAnimationFrame(animationFrameRef.current);
|
||
}
|
||
} else {
|
||
await initAudioPipeline();
|
||
}
|
||
setIsListening((prev) => !prev);
|
||
}, [isListening, initAudioPipeline]);
|
||
|
||
// 清理资源
|
||
useEffect(() => {
|
||
return () => {
|
||
if (isListening) {
|
||
mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||
audioContextRef.current?.close();
|
||
if (animationFrameRef.current) {
|
||
cancelAnimationFrame(animationFrameRef.current);
|
||
}
|
||
}
|
||
};
|
||
}, [isListening]);
|
||
|
||
return (
|
||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center w-full px-4">
|
||
{/* 问候语 */}
|
||
<h1 className="text-6xl font-light mb-8 drop-shadow-glow">{greeting}</h1>
|
||
|
||
{/* 优化后的音频波形可视化 */}
|
||
<div className="relative inline-block">
|
||
<button
|
||
onClick={toggleListening}
|
||
className={[
|
||
"group relative flex h-20 items-end gap-1.5 rounded-3xl p-6",
|
||
"bg-gradient-to-b from-black/80 to-gray-900/90",
|
||
"shadow-[0_0_20px_0_rgba(34,211,238,0.2)] hover:shadow-[0_0_30px_0_rgba(109,213,237,0.3)]",
|
||
"transition-shadow duration-300 ease-out",
|
||
isListening ? "ring-2 ring-cyan-400/20" : "",
|
||
].join(" ")}
|
||
style={{
|
||
// 禁用will-change优化(经测试反而降低性能)
|
||
backdropFilter: "blur(12px)", // 直接使用CSS属性
|
||
WebkitBackdropFilter: "blur(12px)",
|
||
}}
|
||
>
|
||
{/* 优化后的柱状图容器 */}
|
||
<div ref={barsRef} className="flex h-full w-full items-end gap-1.5">
|
||
{[...Array(BAR_COUNT)].map((_, index) => (
|
||
<div
|
||
key={index}
|
||
className={[
|
||
"w-2.5 rounded-full",
|
||
"bg-gradient-to-t from-cyan-400/90 via-blue-400/90 to-purple-500/90",
|
||
"transition-transform duration-150 ease-out",
|
||
].join(" ")}
|
||
style={{
|
||
willChange: "height, transform", // 提示浏览器优化
|
||
boxShadow: "0 0 8px -2px rgba(52,211,254,0.4)",
|
||
height: "10%", // 初始高度
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</button>
|
||
</div>
|
||
|
||
{/* 底部状态信息 */}
|
||
<div className="mt-8 text-xs text-gray-500 space-y-1">
|
||
<p>支持唤醒词:"魔镜魔镜"</p>
|
||
<div className="flex items-center justify-center gap-1.5">
|
||
<div className="relative flex items-center">
|
||
{/* 呼吸圆点指示器 */}
|
||
<div
|
||
className={`w-2 h-2 rounded-full ${
|
||
isListening ? "bg-green-400 animate-breath" : "bg-gray-400"
|
||
}`}
|
||
/>
|
||
{/* 扩散波纹效果 */}
|
||
{isListening && (
|
||
<div className="absolute inset-0 rounded-full bg-green-400/20 animate-ping" />
|
||
)}
|
||
</div>
|
||
<span>音频状态: {isListening ? "监听中" : "待机"}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 错误提示 */}
|
||
{error && (
|
||
<div className="mt-6 text-red-400/90 text-lg animate-fade-in">
|
||
{error}
|
||
<button
|
||
onClick={() => setError(null)}
|
||
className="ml-3 text-gray-400 hover:text-gray-300 transition-colors"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default VoiceAssistant;
|