"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; import { useAudioAnalyser } from "@/hooks/use-audio-analyser"; import { adaptPalette, isDarkTheme, mix, readPalette, rgba, type Palette, type RGB, } 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; }; /** * 水平频谱:一排沿中线上下对称伸展的圆头光柱,左低频、右高频。 * * 整体结构是经典的「音乐播放器频谱」,但做了三处柔化处理, * 让它与波形 / 光环 / 星云共享同一套视觉语言: * * 1. 柱体上下镜像,且两端经窗函数收束——轮廓是一枚梭形,而不是一块矩形; * 2. 颜色沿横轴铺 sky → lavender → rose 渐变,与波形模式的着色完全一致; * 3. 静态时不归零,而是保持低幅呼吸 + 一道缓慢游走的涟漪,表示「在聆听」。 * * 数据通路:AnalyserNode.getByteFrequencyData() 拿到 0~255 的频谱幅值, * 经「频段映射 → 增益曲线 → 快攻慢放平滑」三步,变成每根柱的高度。 */ export function SpectrumVisualizer({ active = false, analyser = null, stream = null, size = 220, barCount = 24, onError, className, }: SpectrumVisualizerProps) { const canvasRef = React.useRef(null); // 每根柱平滑后的当前高度(0~1),跨帧保留才能做缓动 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; // ==================================================================== // 一、画布初始化 // ==================================================================== // 物理像素 = CSS 像素 × dpr,再用 scale 把坐标系换算回 CSS 像素, // 这样后面所有绘制都按 CSS 尺寸写,又能在高分屏上保持清晰。 // dpr 封顶 2:3x 屏的额外清晰度肉眼难辨,填充率却翻倍不止。 const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = size * dpr; canvas.height = size * dpr; ctx.scale(dpr, dpr); // barCount 变化时重建平滑数组(旧值作废,从 0 重新长出来即可) if (smoothRef.current.length !== barCount) { smoothRef.current = new Float32Array(barCount); } const smooth = smoothRef.current; // ==================================================================== // 二、布局常量 // ==================================================================== const cy = size / 2; // 中线:柱体以它为轴上下对称 const pad = size * 0.09; // 左右留白,与波形模式的构图对齐 const span = size - pad * 2; // 频谱实际占据的横向宽度 const slot = span / barCount; // 每根柱占据的横向槽位 const barW = Math.max(2, slot * 0.5); // 柱宽:柱与空隙各占一半,排列才透气 const maxHalf = size * 0.27; // 满音量时柱体向上(向下同)最多伸多高 const minHalf = size * 0.012; // 静音时也保留一个小圆点,排列不会断 // 分析器 fftSize=512 → 256 个频率 bin(见 use-audio-analyser 的默认值) const freq = new Uint8Array(256); // 只取低 70% 的 bin:48kHz 采样下高频段几乎只有噪声, // 人声与音乐的主能量都集中在前段 const usableBins = Math.floor(freq.length * 0.7); // 两端收束的窗函数:x∈[0,1] → 中间 1、两端 0。 // 幂次 0.6 让肩部更平缓,收束只发生在最边缘, // 整排柱的包络因此呈梭形——与波形模式的 taper 同源 const taper = (x: number) => Math.sin(Math.PI * x) ** 0.6; // 沿横轴铺三色渐变:左 sky、中 lavender、右 rose(与波形模式一致) const colorAt = ({ sky, lav, rose }: Palette, p: number): RGB => p < 0.5 ? mix(sky, lav, p * 2) : mix(lav, rose, (p - 0.5) * 2); let raf = 0; let t = 0; // 累计时间(秒),驱动呼吸与涟漪 // ==================================================================== // 三、帧循环 // ==================================================================== const draw = () => { t += 0.016; // 每帧读主题:用户切换明暗模式时无需重建组件即可换色 const dark = isDarkTheme(); const palette = adaptPalette(readPalette(canvas), dark); const node = analyserRef.current; if (node) node.getByteFrequencyData(freq); const breathe = 0.5 + 0.5 * Math.sin(t * 1.1); // 0~1 的慢呼吸 // ---- 1. 计算每根柱的目标高度并平滑 -------------------------------- for (let i = 0; i < barCount; i++) { const p = i / (barCount - 1); // 本柱在横轴上的位置 0~1 let target: number; if (node) { // 频段映射:p^1.7 是一条凹曲线,把更多柱分给低频段。 // 听觉对频率近似对数感知,线性均分会让左边几根柱挤下 // 全部人声、右边大片柱常年趴零 const bin = Math.floor(Math.pow(p, 1.7) * usableBins); // 增益曲线:幅值^1.25 轻微压低小信号, // 环境底噪不再让整排柱毛毛躁躁,说话时的起伏反而更分明 target = Math.pow(freq[bin] / 255, 1.25); } else { // 静态呼吸态 = 基础高度 + 整体慢呼吸 + 一道向右游走的涟漪 // (p*7 - t*1.6:相位随位置递增、随时间回退,视觉上波峰右移) target = 0.05 + 0.04 * breathe + 0.045 * (0.5 + 0.5 * Math.sin(p * 7 - t * 1.6)); } // 快攻慢放:上升快(0.4)让节拍打得动,下降慢(0.12)让回落 // 像余韵而不是抽搐。这是音频表 ballistics 的经典做法 const k = target > smooth[i] ? 0.4 : 0.12; smooth[i] += (target - smooth[i]) * k; } ctx.clearRect(0, 0, size, size); // ---- 2. 频谱柱 ---------------------------------------------------- ctx.lineCap = "round"; // 圆头:柱体首尾都是半圆,最小高度时就是一个圆点 ctx.lineWidth = barW; for (let i = 0; i < barCount; i++) { const p = i / (barCount - 1); const v = smooth[i]; const x = pad + (i + 0.5) * slot; // 高度 = (保底圆点 + 音量伸展) × 两端收束 const half = (minHalf + v * maxHalf) * taper(p); const color = colorAt(palette, p); // 透明度随高度走:安静的柱隐入背景,活跃的柱跳到前景 ctx.strokeStyle = rgba(color, (dark ? 0.4 : 0.5) + v * 0.45); // 柔光也随高度走,响的柱晕染更大 ctx.shadowColor = rgba(color, 0.6); ctx.shadowBlur = 4 + v * 14; ctx.beginPath(); ctx.moveTo(x, cy - half); ctx.lineTo(x, cy + half); ctx.stroke(); } ctx.shadowBlur = 0; raf = requestAnimationFrame(draw); }; raf = requestAnimationFrame(draw); return () => cancelAnimationFrame(raf); }, [size, barCount, analyserRef]); return ( ); }