Skip to content

Commit

Permalink
feat: voice print
Browse files Browse the repository at this point in the history
  • Loading branch information
Dogtiti committed Nov 8, 2024
1 parent 89136fb commit d33e772
Showing 1 changed file with 141 additions and 127 deletions.
268 changes: 141 additions & 127 deletions app/components/voice-print/voice-print.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useCallback } from "react";
import styles from "./voice-print.module.scss";

interface VoicePrintProps {
Expand All @@ -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"]}>
Expand Down

0 comments on commit d33e772

Please sign in to comment.