Refactor SpectrumVisualizer for improved audio visualization and responsiveness
- 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.
This commit is contained in:
@@ -5,22 +5,24 @@ import { cn } from "@/lib/utils";
|
||||
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
|
||||
import {
|
||||
adaptPalette,
|
||||
cyclicColor,
|
||||
isDarkTheme,
|
||||
mix,
|
||||
readPalette,
|
||||
rgba,
|
||||
type Palette,
|
||||
type RGB,
|
||||
} from "@/lib/visualizer-palette";
|
||||
|
||||
export type SpectrumVisualizerProps = {
|
||||
/** 是否激活:true 时采集麦克风并随音量律动,false 时显示静态呼吸态 */
|
||||
/** 是否激活:true 时采集麦克风并随频谱律动,false 时显示静态呼吸态 */
|
||||
active?: boolean;
|
||||
/** 外部分析器;提供后组件不再自行申请麦克风 */
|
||||
analyser?: AnalyserNode | null;
|
||||
/** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */
|
||||
stream?: MediaStream | null;
|
||||
/** 画布直径(px) */
|
||||
/** 画布边长(px,方形画布,频谱居中横贯) */
|
||||
size?: number;
|
||||
/** 环绕的频谱柱数量 */
|
||||
/** 频谱柱数量 */
|
||||
barCount?: number;
|
||||
/** 申请麦克风失败时回调 */
|
||||
onError?: (error: unknown) => void;
|
||||
@@ -28,20 +30,29 @@ export type SpectrumVisualizerProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 径向频谱:左右镜像对称的一圈光柱,从基准环向内外双向伸展,
|
||||
* 低频在顶部、高频在底部。静态时沿圆周泛起呼吸涟漪,
|
||||
* 激活后随频谱起伏。与其他可视化共用同一套调色与柔光语言。
|
||||
* 水平频谱:一排沿中线上下对称伸展的圆头光柱,左低频、右高频。
|
||||
*
|
||||
* 整体结构是经典的「音乐播放器频谱」,但做了三处柔化处理,
|
||||
* 让它与波形 / 光环 / 星云共享同一套视觉语言:
|
||||
*
|
||||
* 1. 柱体上下镜像,且两端经窗函数收束——轮廓是一枚梭形,而不是一块矩形;
|
||||
* 2. 颜色沿横轴铺 sky → lavender → rose 渐变,与波形模式的着色完全一致;
|
||||
* 3. 静态时不归零,而是保持低幅呼吸 + 一道缓慢游走的涟漪,表示「在聆听」。
|
||||
*
|
||||
* 数据通路:AnalyserNode.getByteFrequencyData() 拿到 0~255 的频谱幅值,
|
||||
* 经「频段映射 → 增益曲线 → 快攻慢放平滑」三步,变成每根柱的高度。
|
||||
*/
|
||||
export function SpectrumVisualizer({
|
||||
active = false,
|
||||
analyser = null,
|
||||
stream = null,
|
||||
size = 220,
|
||||
barCount = 96,
|
||||
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 });
|
||||
|
||||
@@ -51,99 +62,116 @@ export function SpectrumVisualizer({
|
||||
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 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 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);
|
||||
const TAU = Math.PI * 2;
|
||||
// 只取低 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;
|
||||
let energy = 0;
|
||||
let t = 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);
|
||||
const breathe = 0.5 + 0.5 * Math.sin(t * 1.1); // 0~1 的慢呼吸
|
||||
|
||||
let sum = 0;
|
||||
// ---- 1. 计算每根柱的目标高度并平滑 --------------------------------
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
// 左右镜像:m 从顶部 0 到底部 1,再原路返回
|
||||
const m = (i < half ? i : barCount - i) / half;
|
||||
const p = i / (barCount - 1); // 本柱在横轴上的位置 0~1
|
||||
|
||||
let target: number;
|
||||
if (node) {
|
||||
// 低频朝上、高频朝下,幂映射拉开低频细节
|
||||
const bin = Math.floor(Math.pow(m, 1.6) * freq.length * 0.7);
|
||||
// 频段映射: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.07 +
|
||||
0.05 * breathe +
|
||||
0.05 * (0.5 + 0.5 * Math.sin(m * 8.0 - t * 1.8));
|
||||
0.05 +
|
||||
0.04 * breathe +
|
||||
0.045 * (0.5 + 0.5 * Math.sin(p * 7 - t * 1.6));
|
||||
}
|
||||
const k = target > smooth[i] ? 0.35 : 0.12;
|
||||
|
||||
// 快攻慢放:上升快(0.4)让节拍打得动,下降慢(0.12)让回落
|
||||
// 像余韵而不是抽搐。这是音频表 ballistics 的经典做法
|
||||
const k = target > smooth[i] ? 0.4 : 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);
|
||||
// ---- 2. 频谱柱 ----------------------------------------------------
|
||||
ctx.lineCap = "round"; // 圆头:柱体首尾都是半圆,最小高度时就是一个圆点
|
||||
ctx.lineWidth = barW;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const p = i / barCount;
|
||||
const angle = p * TAU + rotation;
|
||||
const p = i / (barCount - 1);
|
||||
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);
|
||||
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 * 16;
|
||||
ctx.shadowBlur = 4 + v * 14;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + cos * r0, cy + sin * r0);
|
||||
ctx.lineTo(cx + cos * r1, cy + sin * r1);
|
||||
ctx.moveTo(x, cy - half);
|
||||
ctx.lineTo(x, cy + half);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
Reference in New Issue
Block a user