voice-print.tsx 5.3 KB
import { useEffect, useRef, useCallback } from "react";
import styles from "./voice-print.module.scss";

interface VoicePrintProps {
  frequencies?: Uint8Array;
  isActive?: boolean;
}

export function VoicePrint({ frequencies, isActive }: VoicePrintProps) {
  // Canvas引用,用于获取绘图上下文
  const canvasRef = useRef<HTMLCanvasElement>(null);
  // 存储历史频率数据,用于平滑处理
  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();
    }
  }, []);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    /**
     * 处理高DPI屏幕显示
     * 根据设备像素比例调整canvas实际渲染分辨率
     */
    const dpr = window.devicePixelRatio || 1;
    canvas.width = canvas.offsetWidth * dpr;
    canvas.height = canvas.offsetHeight * dpr;
    ctx.scale(dpr, dpr);

    /**
     * 主要绘制函数
     * 使用requestAnimationFrame实现平滑动画
     * 包含以下步骤:
     * 1. 清空画布
     * 2. 更新历史数据
     * 3. 计算波形点
     * 4. 绘制上下对称的声纹
     */
    const draw = () => {
      // 清空画布
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      if (!frequencies || !isActive) {
        historyRef.current = [];
        return;
      }

      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,
          );
        }
      }

      // 绘制对称的下半部分
      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.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)");

      ctx.fillStyle = gradient;
      ctx.fill();

      animationFrameRef.current = requestAnimationFrame(draw);
    };

    // 启动动画循环
    draw();

    // 清理函数:在组件卸载时取消动画
    return () => {
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current);
      }
    };
  }, [frequencies, isActive, updateHistory]);

  return (
    <div className={styles["voice-print"]}>
      <canvas ref={canvasRef} />
    </div>
  );
}