Implement audio visualizers and refactor AssistantPage
- Introduce three new audio visualizer components: AuraVisualizer, SpectrumVisualizer, and WaveVisualizer, enhancing the audio interaction experience. - Replace the deprecated VoiceVisualizer with the new visualizers, ensuring a cohesive visual language across components. - Update the AssistantPage to support dynamic visualization style switching, improving user engagement during audio interactions. - Refactor DebugVoicePanel to accommodate the new visualizer props and enhance the overall debugging interface.
This commit is contained in:
140
frontend/src/components/ui/spectrum-visualizer.tsx
Normal file
140
frontend/src/components/ui/spectrum-visualizer.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
|
||||
import { cyclicColor, readPalette, rgba } from "@/lib/visualizer-palette";
|
||||
|
||||
export type SpectrumVisualizerProps = {
|
||||
/** 是否激活:true 时采集麦克风并随音量律动,false 时显示静态呼吸态 */
|
||||
active?: boolean;
|
||||
/** 外部分析器;提供后组件不再自行申请麦克风 */
|
||||
analyser?: AnalyserNode | null;
|
||||
/** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */
|
||||
stream?: MediaStream | null;
|
||||
/** 画布直径(px) */
|
||||
size?: number;
|
||||
/** 环绕的频谱柱数量 */
|
||||
barCount?: number;
|
||||
/** 申请麦克风失败时回调 */
|
||||
onError?: (error: unknown) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 径向频谱:一圈围绕中心柔光的细长光柱,随频谱起伏。
|
||||
* 克制、对称,与光环模式共用同一套调色与柔光语言。
|
||||
*/
|
||||
export function SpectrumVisualizer({
|
||||
active = false,
|
||||
analyser = null,
|
||||
stream = null,
|
||||
size = 220,
|
||||
barCount = 64,
|
||||
onError,
|
||||
className,
|
||||
}: SpectrumVisualizerProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const smoothRef = React.useRef<Float32Array>(new Float32Array(barCount));
|
||||
const analyserRef = useAudioAnalyser({ active, analyser, stream, onError });
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = size * dpr;
|
||||
canvas.height = size * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
if (smoothRef.current.length !== barCount) {
|
||||
smoothRef.current = new Float32Array(barCount);
|
||||
}
|
||||
const smooth = smoothRef.current;
|
||||
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const innerR = size * 0.22;
|
||||
const maxBar = size * 0.2;
|
||||
const freq = new Uint8Array(256);
|
||||
|
||||
let raf = 0;
|
||||
let t = 0;
|
||||
|
||||
const draw = () => {
|
||||
t += 0.016;
|
||||
const palette = readPalette(canvas);
|
||||
const { sky, lav } = palette;
|
||||
|
||||
const node = analyserRef.current;
|
||||
if (node) node.getByteFrequencyData(freq);
|
||||
|
||||
let energy = 0;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
let target: number;
|
||||
if (node) {
|
||||
// 取低中频段(人声主能量),映射到一圈
|
||||
const bin = Math.floor((i / barCount) * (freq.length * 0.62));
|
||||
target = freq[bin] / 255;
|
||||
} else {
|
||||
// 静态呼吸态
|
||||
target = 0.08 + 0.05 * (0.5 + 0.5 * Math.sin(t * 1.5 + i * 0.4));
|
||||
}
|
||||
smooth[i] += (target - smooth[i]) * 0.22;
|
||||
energy += smooth[i];
|
||||
}
|
||||
energy /= barCount;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// 中心柔光:和光环模式一致的呼吸光晕
|
||||
const breathe = 0.5 + 0.5 * Math.sin(t * 1.3);
|
||||
const glowR = innerR * (1 + energy * 0.4) + size * 0.04 * breathe;
|
||||
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR + maxBar);
|
||||
glow.addColorStop(0, rgba(sky, 0.32 + energy * 0.4));
|
||||
glow.addColorStop(0.5, rgba(lav, 0.12 + energy * 0.18));
|
||||
glow.addColorStop(1, rgba(lav, 0));
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
// 径向光柱:圆头、细、带柔光,缓慢旋转
|
||||
const rotation = t * 0.08;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineWidth = Math.max(1.5, size * 0.008);
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const angle = (i / barCount) * Math.PI * 2 + rotation;
|
||||
const v = smooth[i];
|
||||
const r0 = innerR + size * 0.012;
|
||||
const r1 = r0 + maxBar * (0.12 + v);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const color = cyclicColor(palette, i / barCount);
|
||||
ctx.strokeStyle = rgba(color, 0.35 + v * 0.5);
|
||||
ctx.shadowColor = rgba(color, 0.7);
|
||||
ctx.shadowBlur = 6 + v * 14;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + cos * r0, cy + sin * r0);
|
||||
ctx.lineTo(cx + cos * r1, cy + sin * r1);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [size, barCount, analyserRef]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label="麦克风音频可视化(频谱)"
|
||||
style={{ width: size, height: size }}
|
||||
className={cn("select-none", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user