182 lines
5.6 KiB
TypeScript
182 lines
5.6 KiB
TypeScript
"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 NebulaVisualizerProps = {
|
|||
|
|
/** 是否激活:true 时采集麦克风并随音频律动,false 时显示静态呼吸态 */
|
|||
|
|
active?: boolean;
|
|||
|
|
/** 外部分析器;提供后组件不再自行申请麦克风 */
|
|||
|
|
analyser?: AnalyserNode | null;
|
|||
|
|
/** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */
|
|||
|
|
stream?: MediaStream | null;
|
|||
|
|
/** 画布直径(px) */
|
|||
|
|
size?: number;
|
|||
|
|
/** 粒子数量 */
|
|||
|
|
particleCount?: number;
|
|||
|
|
/** 申请麦克风失败时回调 */
|
|||
|
|
onError?: (error: unknown) => void;
|
|||
|
|
className?: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type Particle = {
|
|||
|
|
/** 当前角度(rad) */
|
|||
|
|
ang: number;
|
|||
|
|
/** 角速度(rad/s,带方向) */
|
|||
|
|
vel: number;
|
|||
|
|
/** 基础轨道半径(占画布尺寸比例) */
|
|||
|
|
baseR: number;
|
|||
|
|
/** 呼吸相位偏移 */
|
|||
|
|
phase: number;
|
|||
|
|
/** 基础粒径(px @220 画布) */
|
|||
|
|
sz: number;
|
|||
|
|
/** 在调色板上的取色位置 */
|
|||
|
|
hue: number;
|
|||
|
|
/** 平滑后的所在频段能量 */
|
|||
|
|
v: number;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 星云:一群沿环形轨道缓慢漂移的发光粒子,带运动拖尾。
|
|||
|
|
* 静态时如星环般缓慢呼吸流转;激活后粒子按所在方位
|
|||
|
|
* 对应的频段能量加速、外扩、增亮。
|
|||
|
|
*/
|
|||
|
|
export function NebulaVisualizer({
|
|||
|
|
active = false,
|
|||
|
|
analyser = null,
|
|||
|
|
stream = null,
|
|||
|
|
size = 220,
|
|||
|
|
particleCount = 140,
|
|||
|
|
onError,
|
|||
|
|
className,
|
|||
|
|
}: NebulaVisualizerProps) {
|
|||
|
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
|||
|
|
const particlesRef = React.useRef<Particle[]>([]);
|
|||
|
|
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);
|
|||
|
|
|
|||
|
|
const TAU = Math.PI * 2;
|
|||
|
|
if (particlesRef.current.length !== particleCount) {
|
|||
|
|
particlesRef.current = Array.from({ length: particleCount }, () => ({
|
|||
|
|
ang: Math.random() * TAU,
|
|||
|
|
vel: (0.08 + Math.random() * 0.22) * (Math.random() < 0.5 ? -1 : 1),
|
|||
|
|
baseR: 0.27 + Math.random() * 0.15,
|
|||
|
|
phase: Math.random() * TAU,
|
|||
|
|
sz: 0.7 + Math.random() * 1.5,
|
|||
|
|
hue: Math.random(),
|
|||
|
|
v: 0,
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
const particles = particlesRef.current;
|
|||
|
|
|
|||
|
|
const cx = size / 2;
|
|||
|
|
const cy = size / 2;
|
|||
|
|
const scale = size / 220;
|
|||
|
|
const freq = new Uint8Array(256);
|
|||
|
|
const dt = 0.016;
|
|||
|
|
|
|||
|
|
let raf = 0;
|
|||
|
|
let t = 0;
|
|||
|
|
let energy = 0;
|
|||
|
|
|
|||
|
|
const draw = () => {
|
|||
|
|
t += dt;
|
|||
|
|
const dark = isDarkTheme();
|
|||
|
|
const palette = adaptPalette(readPalette(canvas), dark);
|
|||
|
|
const { sky, lav } = palette;
|
|||
|
|
|
|||
|
|
const node = analyserRef.current;
|
|||
|
|
let level = 0;
|
|||
|
|
if (node) {
|
|||
|
|
node.getByteFrequencyData(freq);
|
|||
|
|
const bins = Math.floor(freq.length * 0.6);
|
|||
|
|
let sum = 0;
|
|||
|
|
for (let i = 0; i < bins; i++) sum += freq[i];
|
|||
|
|
level = sum / bins / 255;
|
|||
|
|
}
|
|||
|
|
energy += (level - energy) * (level > energy ? 0.3 : 0.08);
|
|||
|
|
|
|||
|
|
const breathe = 0.5 + 0.5 * Math.sin(t * 0.8);
|
|||
|
|
|
|||
|
|
// 用 destination-out 让上一帧整体淡出,留下运动拖尾
|
|||
|
|
ctx.globalCompositeOperation = "destination-out";
|
|||
|
|
ctx.fillStyle = "rgba(0, 0, 0, 0.16)";
|
|||
|
|
ctx.fillRect(0, 0, size, size);
|
|||
|
|
ctx.globalCompositeOperation = "source-over";
|
|||
|
|
|
|||
|
|
// 中心柔光逐帧低强度补画,与淡出达到稳态平衡
|
|||
|
|
const glowR = size * (0.16 + 0.02 * breathe) * (1 + energy * 0.6);
|
|||
|
|
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR * 2);
|
|||
|
|
glow.addColorStop(0, rgba(sky, 0.045 + energy * 0.09));
|
|||
|
|
glow.addColorStop(0.6, rgba(lav, 0.02 + energy * 0.04));
|
|||
|
|
glow.addColorStop(1, rgba(lav, 0));
|
|||
|
|
ctx.fillStyle = glow;
|
|||
|
|
ctx.fillRect(0, 0, size, size);
|
|||
|
|
|
|||
|
|
for (const p of particles) {
|
|||
|
|
p.ang += p.vel * dt * (1 + energy * 2.2);
|
|||
|
|
|
|||
|
|
// 粒子方位映射到频段(左右镜像,低频在顶部)
|
|||
|
|
const a01 = (((p.ang + Math.PI / 2) % TAU) + TAU) % TAU / TAU;
|
|||
|
|
const m = a01 < 0.5 ? a01 * 2 : (1 - a01) * 2;
|
|||
|
|
let target = 0;
|
|||
|
|
if (node) {
|
|||
|
|
const bin = Math.floor(Math.pow(m, 1.5) * freq.length * 0.6);
|
|||
|
|
target = Math.pow(freq[bin] / 255, 1.3);
|
|||
|
|
}
|
|||
|
|
p.v += (target - p.v) * (target > p.v ? 0.3 : 0.1);
|
|||
|
|
|
|||
|
|
const wobble =
|
|||
|
|
0.016 * Math.sin(t * 0.9 + p.phase) + 0.014 * (breathe - 0.5);
|
|||
|
|
const rad = (p.baseR + wobble + p.v * 0.1) * size;
|
|||
|
|
const x = cx + Math.cos(p.ang) * rad;
|
|||
|
|
const y = cy + Math.sin(p.ang) * rad;
|
|||
|
|
|
|||
|
|
const color = cyclicColor(palette, p.hue + t * 0.02);
|
|||
|
|
const lum =
|
|||
|
|
0.3 + 0.2 * (0.5 + 0.5 * Math.sin(t * 1.3 + p.phase)) + 0.55 * p.v;
|
|||
|
|
ctx.fillStyle = rgba(color, Math.min(1, lum + (dark ? 0 : 0.12)));
|
|||
|
|
ctx.shadowColor = rgba(color, 0.7);
|
|||
|
|
ctx.shadowBlur = 3 + p.v * 12;
|
|||
|
|
ctx.beginPath();
|
|||
|
|
ctx.arc(x, y, p.sz * scale * (1 + p.v * 1.4), 0, TAU);
|
|||
|
|
ctx.fill();
|
|||
|
|
}
|
|||
|
|
ctx.shadowBlur = 0;
|
|||
|
|
|
|||
|
|
raf = requestAnimationFrame(draw);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
raf = requestAnimationFrame(draw);
|
|||
|
|
return () => cancelAnimationFrame(raf);
|
|||
|
|
}, [size, particleCount, analyserRef]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<canvas
|
|||
|
|
ref={canvasRef}
|
|||
|
|
role="img"
|
|||
|
|
aria-label="麦克风音频可视化(星云)"
|
|||
|
|
style={{ width: size, height: size }}
|
|||
|
|
className={cn("select-none", className)}
|
|||
|
|
/>
|
|||
|
|
);
|
|||
|
|
}
|