- Update SpectrumVisualizer component to enhance the visual representation of audio frequencies with a new layout and smoother animations. - Modify prop descriptions for clarity and adjust the number of frequency bars for better performance. - Implement a refined drawing logic that maintains visual consistency across different themes and improves the overall user experience during audio playback.
196 lines
7.9 KiB
TypeScript
196 lines
7.9 KiB
TypeScript
"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<HTMLCanvasElement>(null);
|
||
// 每根柱平滑后的当前高度(0~1),跨帧保留才能做缓动
|
||
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;
|
||
|
||
// ====================================================================
|
||
// 一、画布初始化
|
||
// ====================================================================
|
||
// 物理像素 = 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 (
|
||
<canvas
|
||
ref={canvasRef}
|
||
role="img"
|
||
aria-label="麦克风音频可视化(频谱)"
|
||
style={{ width: size, height: size }}
|
||
className={cn("select-none", className)}
|
||
/>
|
||
);
|
||
}
|