Files
ai-video-fullstack/frontend/src/components/ui/nebula-visualizer.tsx
Xin Wang df7ce493f1 Enhance audio visualizers with new NebulaVisualizer and refactor existing components
- Introduce the NebulaVisualizer component, featuring particles that respond to audio input, enhancing the visual experience.
- Refactor AuraVisualizer, SpectrumVisualizer, and WaveVisualizer to utilize the adaptPalette function for improved theme handling.
- Update visualizer logic to enhance responsiveness and visual effects based on audio analysis, ensuring a cohesive user experience across components.
2026-06-10 09:17:14 +08:00

182 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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)}
/>
);
}