Files
ai-video-fullstack/frontend/src/components/ui/spectrum-visualizer.tsx
Xin Wang df7ce493f1 Enhance audio visualizers with new NebulaVisualizer and refactor existing components
- Introduce the NebulaVisualizer component, featuring particles that respond to audio input, enhancing the visual experience.
- Refactor AuraVisualizer, SpectrumVisualizer, and WaveVisualizer to utilize the adaptPalette function for improved theme handling.
- Update visualizer logic to enhance responsiveness and visual effects based on audio analysis, ensuring a cohesive user experience across components.
2026-06-10 09:17:14 +08:00

168 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import {
adaptPalette,
cyclicColor,
isDarkTheme,
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 = 96,
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 baseR = size * 0.3;
const outLen = size * 0.16;
const inLen = size * 0.055;
const half = Math.floor(barCount / 2);
const freq = new Uint8Array(256);
const TAU = Math.PI * 2;
let raf = 0;
let t = 0;
let energy = 0;
const draw = () => {
t += 0.016;
const dark = isDarkTheme();
const palette = adaptPalette(readPalette(canvas), dark);
const { sky, lav } = palette;
const node = analyserRef.current;
if (node) node.getByteFrequencyData(freq);
const breathe = 0.5 + 0.5 * Math.sin(t * 1.1);
let sum = 0;
for (let i = 0; i < barCount; i++) {
// 左右镜像m 从顶部 0 到底部 1再原路返回
const m = (i < half ? i : barCount - i) / half;
let target: number;
if (node) {
// 低频朝上、高频朝下,幂映射拉开低频细节
const bin = Math.floor(Math.pow(m, 1.6) * freq.length * 0.7);
target = Math.pow(freq[bin] / 255, 1.25);
} else {
// 静态呼吸 + 沿圆周缓慢游走的涟漪
target =
0.07 +
0.05 * breathe +
0.05 * (0.5 + 0.5 * Math.sin(m * 8.0 - t * 1.8));
}
const k = target > smooth[i] ? 0.35 : 0.12;
smooth[i] += (target - smooth[i]) * k;
sum += smooth[i];
}
energy += (sum / barCount - energy) * 0.1;
ctx.clearRect(0, 0, size, size);
// 中心柔光:与其他模式一致的呼吸光晕
const glowR = baseR * (0.9 + energy * 0.5) + size * 0.03 * breathe;
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR + outLen);
glow.addColorStop(0, rgba(sky, (dark ? 0.3 : 0.2) + energy * 0.35));
glow.addColorStop(0.55, rgba(lav, 0.1 + energy * 0.15));
glow.addColorStop(1, rgba(lav, 0));
ctx.fillStyle = glow;
ctx.fillRect(0, 0, size, size);
// 基准环:一条贯穿所有光柱的发丝细环
ctx.beginPath();
ctx.arc(cx, cy, baseR, 0, TAU);
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(lav, dark ? 0.28 : 0.32);
ctx.stroke();
// 镜像光柱:从基准环向内外双向伸展,圆头、细、带柔光
const rotation = -Math.PI / 2 + Math.sin(t * 0.11) * 0.06;
ctx.lineCap = "round";
ctx.lineWidth = Math.max(1.3, size * 0.007);
for (let i = 0; i < barCount; i++) {
const p = i / barCount;
const angle = p * TAU + rotation;
const v = smooth[i];
const r0 = baseR - 1.5 - inLen * v;
const r1 = baseR + 1.5 + outLen * (0.08 + 0.92 * v);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const color = cyclicColor(palette, p + t * 0.015);
ctx.strokeStyle = rgba(color, (dark ? 0.35 : 0.45) + v * 0.5);
ctx.shadowColor = rgba(color, 0.6);
ctx.shadowBlur = 4 + v * 16;
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)}
/>
);
}