"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(null); const smoothRef = React.useRef(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 ( ); }