|
|
@@ -1,4 +1,4 @@
|
|
|
-import { useEffect, useRef } from "react";
|
|
|
+import { useEffect, useRef, useCallback } from "react";
|
|
|
import styles from "./voice-print.module.scss";
|
|
|
|
|
|
interface VoicePrintProps {
|
|
|
@@ -7,156 +7,170 @@ interface VoicePrintProps {
|
|
|
}
|
|
|
|
|
|
export function VoicePrint({ frequencies, isActive }: VoicePrintProps) {
|
|
|
+ // Canvas引用,用于获取绘图上下文
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
- const historyRef = useRef<number[][]>([]); // 存储历史频率数据,用于平滑处理
|
|
|
- const historyLengthRef = useRef(10); // 历史数据保留帧数,影响平滑程度
|
|
|
- const animationFrameRef = useRef<number>(); // 用于管理动画帧
|
|
|
- const currentFrequenciesRef = useRef<Uint8Array>(); // 当前频率数据的引用
|
|
|
- const amplitudeMultiplier = useRef(1.5); // 波形振幅倍数,控制波形高度
|
|
|
-
|
|
|
- // 更新频率数据的副作用
|
|
|
- useEffect(() => {
|
|
|
- if (!frequencies || !isActive) {
|
|
|
- historyRef.current = [];
|
|
|
- currentFrequenciesRef.current = undefined;
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- currentFrequenciesRef.current = frequencies;
|
|
|
- const freqArray = Array.from(frequencies);
|
|
|
- const newHistory = [...historyRef.current, freqArray];
|
|
|
- if (newHistory.length > historyLengthRef.current) {
|
|
|
- newHistory.shift();
|
|
|
+ // 存储历史频率数据,用于平滑处理
|
|
|
+ const historyRef = useRef<number[][]>([]);
|
|
|
+ // 控制保留的历史数据帧数,影响平滑度
|
|
|
+ const historyLengthRef = useRef(10);
|
|
|
+ // 存储动画帧ID,用于清理
|
|
|
+ const animationFrameRef = useRef<number>();
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 更新频率历史数据
|
|
|
+ * 使用FIFO队列维护固定长度的历史记录
|
|
|
+ */
|
|
|
+ const updateHistory = useCallback((freqArray: number[]) => {
|
|
|
+ historyRef.current.push(freqArray);
|
|
|
+ if (historyRef.current.length > historyLengthRef.current) {
|
|
|
+ historyRef.current.shift();
|
|
|
}
|
|
|
- historyRef.current = newHistory;
|
|
|
- }, [frequencies, isActive]);
|
|
|
+ }, []);
|
|
|
|
|
|
- // 渲染函数:负责绘制声纹动画
|
|
|
- const render = () => {
|
|
|
+ useEffect(() => {
|
|
|
const canvas = canvasRef.current;
|
|
|
- const frequencies = currentFrequenciesRef.current;
|
|
|
-
|
|
|
- if (!canvas || !frequencies || !isActive) return;
|
|
|
+ if (!canvas) return;
|
|
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
|
if (!ctx) return;
|
|
|
|
|
|
- // 清空画布
|
|
|
- ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
-
|
|
|
- const points: [number, number][] = [];
|
|
|
- const centerY = canvas.height / 2;
|
|
|
- const width = canvas.width;
|
|
|
-
|
|
|
- // 频率采样处理
|
|
|
- // 将输入的频率数据重采样为128个点,减少计算量并保持显示效果
|
|
|
- const frequencyStep = Math.ceil(frequencies.length / 128); // 计算采样间隔
|
|
|
- const effectiveFrequencies = Array.from(
|
|
|
- { length: 128 },
|
|
|
- (_, i) => frequencies[i * frequencyStep] || 0,
|
|
|
- );
|
|
|
-
|
|
|
- // 计算每个频率点在画布上的水平间距
|
|
|
- const sliceWidth = width / (effectiveFrequencies.length - 1);
|
|
|
-
|
|
|
- ctx.beginPath();
|
|
|
- ctx.moveTo(0, centerY);
|
|
|
-
|
|
|
- // 遍历采样后的频率数据,计算并绘制波形
|
|
|
- for (let i = 0; i < effectiveFrequencies.length; i++) {
|
|
|
- const x = i * sliceWidth;
|
|
|
- let avgFrequency = effectiveFrequencies[i];
|
|
|
-
|
|
|
- // 使用历史数据进行平滑处理
|
|
|
- // 当前值权重为2,历史数据权重为1,实现平滑过渡
|
|
|
- if (historyRef.current.length > 0) {
|
|
|
- const historicalValues = historyRef.current.map(
|
|
|
- (h) => h[i * frequencyStep] || 0,
|
|
|
- );
|
|
|
- avgFrequency =
|
|
|
- (avgFrequency * 2 + historicalValues.reduce((a, b) => a + b, 0)) /
|
|
|
- (historyRef.current.length + 2);
|
|
|
- }
|
|
|
+ /**
|
|
|
+ * 处理高DPI屏幕显示
|
|
|
+ * 根据设备像素比例调整canvas实际渲染分辨率
|
|
|
+ */
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
+ canvas.width = canvas.offsetWidth * dpr;
|
|
|
+ canvas.height = canvas.offsetHeight * dpr;
|
|
|
+ ctx.scale(dpr, dpr);
|
|
|
|
|
|
- // 波形计算
|
|
|
- const normalized = Math.pow(avgFrequency / 255.0, 1.1); // 使用幂函数增强对比度
|
|
|
- const height =
|
|
|
- normalized * (canvas.height / 2) * amplitudeMultiplier.current;
|
|
|
- // 使用正弦函数创建波动效果,i * 0.15控制波形密度,Date.now() * 0.003控制波动速度
|
|
|
- const y = centerY + height * Math.sin(i * 0.15 + Date.now() * 0.003);
|
|
|
-
|
|
|
- points.push([x, y]);
|
|
|
-
|
|
|
- // 使用贝塞尔曲线绘制平滑波形
|
|
|
- if (i === 0) {
|
|
|
- ctx.moveTo(x, y);
|
|
|
- } else {
|
|
|
- const prevPoint = points[i - 1];
|
|
|
- const midX = (prevPoint[0] + x) / 2;
|
|
|
- // 二次贝塞尔曲线,使用中点作为控制点
|
|
|
- ctx.quadraticCurveTo(
|
|
|
- prevPoint[0],
|
|
|
- prevPoint[1],
|
|
|
- midX,
|
|
|
- (prevPoint[1] + y) / 2,
|
|
|
- );
|
|
|
+ /**
|
|
|
+ * 主要绘制函数
|
|
|
+ * 使用requestAnimationFrame实现平滑动画
|
|
|
+ * 包含以下步骤:
|
|
|
+ * 1. 清空画布
|
|
|
+ * 2. 更新历史数据
|
|
|
+ * 3. 计算波形点
|
|
|
+ * 4. 绘制上下对称的声纹
|
|
|
+ */
|
|
|
+ const draw = () => {
|
|
|
+ // 清空画布
|
|
|
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
+
|
|
|
+ if (!frequencies || !isActive) {
|
|
|
+ historyRef.current = [];
|
|
|
+ return;
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- // 绘制对称的下半部分波形,创建镜像效果
|
|
|
- for (let i = points.length - 1; i >= 0; i--) {
|
|
|
- const [x, y] = points[i];
|
|
|
- const symmetricY = centerY - (y - centerY);
|
|
|
- if (i === points.length - 1) {
|
|
|
- ctx.lineTo(x, symmetricY);
|
|
|
- } else {
|
|
|
- const nextPoint = points[i + 1];
|
|
|
- const midX = (nextPoint[0] + x) / 2;
|
|
|
- ctx.quadraticCurveTo(
|
|
|
- nextPoint[0],
|
|
|
- centerY - (nextPoint[1] - centerY),
|
|
|
- midX,
|
|
|
- centerY - ((nextPoint[1] + y) / 2 - centerY),
|
|
|
- );
|
|
|
+ const freqArray = Array.from(frequencies);
|
|
|
+ updateHistory(freqArray);
|
|
|
+
|
|
|
+ // 绘制声纹
|
|
|
+ const points: [number, number][] = [];
|
|
|
+ const centerY = canvas.height / 2;
|
|
|
+ const width = canvas.width;
|
|
|
+ const sliceWidth = width / (frequencies.length - 1);
|
|
|
+
|
|
|
+ // 绘制主波形
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(0, centerY);
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 声纹绘制算法:
|
|
|
+ * 1. 使用历史数据平均值实现平滑过渡
|
|
|
+ * 2. 通过正弦函数添加自然波动
|
|
|
+ * 3. 使用贝塞尔曲线连接点,使曲线更平滑
|
|
|
+ * 4. 绘制对称部分形成完整声纹
|
|
|
+ */
|
|
|
+ for (let i = 0; i < frequencies.length; i++) {
|
|
|
+ const x = i * sliceWidth;
|
|
|
+ let avgFrequency = frequencies[i];
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 波形平滑处理:
|
|
|
+ * 1. 收集历史数据中对应位置的频率值
|
|
|
+ * 2. 计算当前值与历史值的加权平均
|
|
|
+ * 3. 根据平均值计算实际显示高度
|
|
|
+ */
|
|
|
+ if (historyRef.current.length > 0) {
|
|
|
+ const historicalValues = historyRef.current.map((h) => h[i] || 0);
|
|
|
+ avgFrequency =
|
|
|
+ (avgFrequency + historicalValues.reduce((a, b) => a + b, 0)) /
|
|
|
+ (historyRef.current.length + 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 波形变换:
|
|
|
+ * 1. 归一化频率值到0-1范围
|
|
|
+ * 2. 添加时间相关的正弦变换
|
|
|
+ * 3. 使用贝塞尔曲线平滑连接点
|
|
|
+ */
|
|
|
+ const normalized = avgFrequency / 255.0;
|
|
|
+ const height = normalized * (canvas.height / 2);
|
|
|
+ const y = centerY + height * Math.sin(i * 0.2 + Date.now() * 0.002);
|
|
|
+
|
|
|
+ points.push([x, y]);
|
|
|
+
|
|
|
+ if (i === 0) {
|
|
|
+ ctx.moveTo(x, y);
|
|
|
+ } else {
|
|
|
+ // 使用贝塞尔曲线使波形更平滑
|
|
|
+ const prevPoint = points[i - 1];
|
|
|
+ const midX = (prevPoint[0] + x) / 2;
|
|
|
+ ctx.quadraticCurveTo(
|
|
|
+ prevPoint[0],
|
|
|
+ prevPoint[1],
|
|
|
+ midX,
|
|
|
+ (prevPoint[1] + y) / 2,
|
|
|
+ );
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- ctx.closePath();
|
|
|
-
|
|
|
- // 创建水平渐变效果
|
|
|
- const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
|
|
- gradient.addColorStop(0, "rgba(100, 180, 255, 0.95)"); // 左侧颜色
|
|
|
- gradient.addColorStop(0.5, "rgba(140, 200, 255, 0.9)"); // 中间颜色
|
|
|
- gradient.addColorStop(1, "rgba(180, 220, 255, 0.95)"); // 右侧颜色
|
|
|
+ // 绘制对称的下半部分
|
|
|
+ for (let i = points.length - 1; i >= 0; i--) {
|
|
|
+ const [x, y] = points[i];
|
|
|
+ const symmetricY = centerY - (y - centerY);
|
|
|
+ if (i === points.length - 1) {
|
|
|
+ ctx.lineTo(x, symmetricY);
|
|
|
+ } else {
|
|
|
+ const nextPoint = points[i + 1];
|
|
|
+ const midX = (nextPoint[0] + x) / 2;
|
|
|
+ ctx.quadraticCurveTo(
|
|
|
+ nextPoint[0],
|
|
|
+ centerY - (nextPoint[1] - centerY),
|
|
|
+ midX,
|
|
|
+ centerY - ((nextPoint[1] + y) / 2 - centerY),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- ctx.fillStyle = gradient;
|
|
|
- ctx.fill();
|
|
|
+ ctx.closePath();
|
|
|
|
|
|
- animationFrameRef.current = requestAnimationFrame(render);
|
|
|
- };
|
|
|
+ /**
|
|
|
+ * 渐变效果:
|
|
|
+ * 从左到右应用三色渐变,带透明度
|
|
|
+ * 使用蓝色系配色提升视觉效果
|
|
|
+ */
|
|
|
+ const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
|
|
+ gradient.addColorStop(0, "rgba(100, 180, 255, 0.95)");
|
|
|
+ gradient.addColorStop(0.5, "rgba(140, 200, 255, 0.9)");
|
|
|
+ gradient.addColorStop(1, "rgba(180, 220, 255, 0.95)");
|
|
|
|
|
|
- // 初始化canvas和动画循环
|
|
|
- useEffect(() => {
|
|
|
- const canvas = canvasRef.current;
|
|
|
- if (!canvas) return;
|
|
|
-
|
|
|
- // 处理高DPI显示器
|
|
|
- const dpr = window.devicePixelRatio || 1;
|
|
|
- canvas.width = canvas.offsetWidth * dpr;
|
|
|
- canvas.height = canvas.offsetHeight * dpr;
|
|
|
+ ctx.fillStyle = gradient;
|
|
|
+ ctx.fill();
|
|
|
|
|
|
- const ctx = canvas.getContext("2d");
|
|
|
- if (!ctx) return;
|
|
|
- ctx.scale(dpr, dpr);
|
|
|
+ animationFrameRef.current = requestAnimationFrame(draw);
|
|
|
+ };
|
|
|
|
|
|
- render();
|
|
|
+ // 启动动画循环
|
|
|
+ draw();
|
|
|
|
|
|
+ // 清理函数:在组件卸载时取消动画
|
|
|
return () => {
|
|
|
if (animationFrameRef.current) {
|
|
|
cancelAnimationFrame(animationFrameRef.current);
|
|
|
}
|
|
|
};
|
|
|
- }, []);
|
|
|
+ }, [frequencies, isActive, updateHistory]);
|
|
|
|
|
|
return (
|
|
|
<div className={styles["voice-print"]}>
|