2026-06-09 16:28:45 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import * as React from "react";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
|
2026-06-10 09:17:14 +08:00
|
|
|
|
import {
|
|
|
|
|
|
adaptPalette,
|
|
|
|
|
|
cyclicColor,
|
|
|
|
|
|
isDarkTheme,
|
|
|
|
|
|
readPalette,
|
|
|
|
|
|
rgba,
|
|
|
|
|
|
} from "@/lib/visualizer-palette";
|
2026-06-09 16:28:45 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-10 09:17:14 +08:00
|
|
|
|
* 径向频谱:左右镜像对称的一圈光柱,从基准环向内外双向伸展,
|
|
|
|
|
|
* 低频在顶部、高频在底部。静态时沿圆周泛起呼吸涟漪,
|
|
|
|
|
|
* 激活后随频谱起伏。与其他可视化共用同一套调色与柔光语言。
|
2026-06-09 16:28:45 +08:00
|
|
|
|
*/
|
|
|
|
|
|
export function SpectrumVisualizer({
|
|
|
|
|
|
active = false,
|
|
|
|
|
|
analyser = null,
|
|
|
|
|
|
stream = null,
|
|
|
|
|
|
size = 220,
|
2026-06-10 09:17:14 +08:00
|
|
|
|
barCount = 96,
|
2026-06-09 16:28:45 +08:00
|
|
|
|
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;
|
2026-06-10 09:17:14 +08:00
|
|
|
|
const baseR = size * 0.3;
|
|
|
|
|
|
const outLen = size * 0.16;
|
|
|
|
|
|
const inLen = size * 0.055;
|
|
|
|
|
|
const half = Math.floor(barCount / 2);
|
2026-06-09 16:28:45 +08:00
|
|
|
|
const freq = new Uint8Array(256);
|
2026-06-10 09:17:14 +08:00
|
|
|
|
const TAU = Math.PI * 2;
|
2026-06-09 16:28:45 +08:00
|
|
|
|
|
|
|
|
|
|
let raf = 0;
|
|
|
|
|
|
let t = 0;
|
2026-06-10 09:17:14 +08:00
|
|
|
|
let energy = 0;
|
2026-06-09 16:28:45 +08:00
|
|
|
|
|
|
|
|
|
|
const draw = () => {
|
|
|
|
|
|
t += 0.016;
|
2026-06-10 09:17:14 +08:00
|
|
|
|
const dark = isDarkTheme();
|
|
|
|
|
|
const palette = adaptPalette(readPalette(canvas), dark);
|
2026-06-09 16:28:45 +08:00
|
|
|
|
const { sky, lav } = palette;
|
|
|
|
|
|
|
|
|
|
|
|
const node = analyserRef.current;
|
|
|
|
|
|
if (node) node.getByteFrequencyData(freq);
|
|
|
|
|
|
|
2026-06-10 09:17:14 +08:00
|
|
|
|
const breathe = 0.5 + 0.5 * Math.sin(t * 1.1);
|
|
|
|
|
|
|
|
|
|
|
|
let sum = 0;
|
2026-06-09 16:28:45 +08:00
|
|
|
|
for (let i = 0; i < barCount; i++) {
|
2026-06-10 09:17:14 +08:00
|
|
|
|
// 左右镜像:m 从顶部 0 到底部 1,再原路返回
|
|
|
|
|
|
const m = (i < half ? i : barCount - i) / half;
|
2026-06-09 16:28:45 +08:00
|
|
|
|
let target: number;
|
|
|
|
|
|
if (node) {
|
2026-06-10 09:17:14 +08:00
|
|
|
|
// 低频朝上、高频朝下,幂映射拉开低频细节
|
|
|
|
|
|
const bin = Math.floor(Math.pow(m, 1.6) * freq.length * 0.7);
|
|
|
|
|
|
target = Math.pow(freq[bin] / 255, 1.25);
|
2026-06-09 16:28:45 +08:00
|
|
|
|
} else {
|
2026-06-10 09:17:14 +08:00
|
|
|
|
// 静态呼吸 + 沿圆周缓慢游走的涟漪
|
|
|
|
|
|
target =
|
|
|
|
|
|
0.07 +
|
|
|
|
|
|
0.05 * breathe +
|
|
|
|
|
|
0.05 * (0.5 + 0.5 * Math.sin(m * 8.0 - t * 1.8));
|
2026-06-09 16:28:45 +08:00
|
|
|
|
}
|
2026-06-10 09:17:14 +08:00
|
|
|
|
const k = target > smooth[i] ? 0.35 : 0.12;
|
|
|
|
|
|
smooth[i] += (target - smooth[i]) * k;
|
|
|
|
|
|
sum += smooth[i];
|
2026-06-09 16:28:45 +08:00
|
|
|
|
}
|
2026-06-10 09:17:14 +08:00
|
|
|
|
energy += (sum / barCount - energy) * 0.1;
|
2026-06-09 16:28:45 +08:00
|
|
|
|
|
|
|
|
|
|
ctx.clearRect(0, 0, size, size);
|
|
|
|
|
|
|
2026-06-10 09:17:14 +08:00
|
|
|
|
// 中心柔光:与其他模式一致的呼吸光晕
|
|
|
|
|
|
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));
|
2026-06-09 16:28:45 +08:00
|
|
|
|
glow.addColorStop(1, rgba(lav, 0));
|
|
|
|
|
|
ctx.fillStyle = glow;
|
|
|
|
|
|
ctx.fillRect(0, 0, size, size);
|
|
|
|
|
|
|
2026-06-10 09:17:14 +08:00
|
|
|
|
// 基准环:一条贯穿所有光柱的发丝细环
|
|
|
|
|
|
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;
|
2026-06-09 16:28:45 +08:00
|
|
|
|
ctx.lineCap = "round";
|
2026-06-10 09:17:14 +08:00
|
|
|
|
ctx.lineWidth = Math.max(1.3, size * 0.007);
|
2026-06-09 16:28:45 +08:00
|
|
|
|
for (let i = 0; i < barCount; i++) {
|
2026-06-10 09:17:14 +08:00
|
|
|
|
const p = i / barCount;
|
|
|
|
|
|
const angle = p * TAU + rotation;
|
2026-06-09 16:28:45 +08:00
|
|
|
|
const v = smooth[i];
|
2026-06-10 09:17:14 +08:00
|
|
|
|
const r0 = baseR - 1.5 - inLen * v;
|
|
|
|
|
|
const r1 = baseR + 1.5 + outLen * (0.08 + 0.92 * v);
|
2026-06-09 16:28:45 +08:00
|
|
|
|
const cos = Math.cos(angle);
|
|
|
|
|
|
const sin = Math.sin(angle);
|
2026-06-10 09:17:14 +08:00
|
|
|
|
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;
|
2026-06-09 16:28:45 +08:00
|
|
|
|
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)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|