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.
This commit is contained in:
Xin Wang
2026-06-10 09:17:14 +08:00
parent 6e83396d64
commit df7ce493f1
4 changed files with 313 additions and 69 deletions

View File

@@ -3,7 +3,13 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { cyclicColor, readPalette, rgba } from "@/lib/visualizer-palette";
import {
adaptPalette,
cyclicColor,
isDarkTheme,
readPalette,
rgba,
} from "@/lib/visualizer-palette";
export type SpectrumVisualizerProps = {
/** 是否激活true 时采集麦克风并随音量律动false 时显示静态呼吸态 */
@@ -22,15 +28,16 @@ export type SpectrumVisualizerProps = {
};
/**
* 径向频谱:一圈围绕中心柔光的细长光柱,随频谱起伏。
* 克制、对称,与光环模式共用同一套调色与柔光语言。
* 径向频谱:左右镜像对称的一圈光柱,从基准环向内外双向伸展,
* 低频在顶部、高频在底部。静态时沿圆周泛起呼吸涟漪,
* 激活后随频谱起伏。与其他可视化共用同一套调色与柔光语言。
*/
export function SpectrumVisualizer({
active = false,
analyser = null,
stream = null,
size = 220,
barCount = 64,
barCount = 96,
onError,
className,
}: SpectrumVisualizerProps) {
@@ -56,64 +63,84 @@ export function SpectrumVisualizer({
const cx = size / 2;
const cy = size / 2;
const innerR = size * 0.22;
const maxBar = size * 0.2;
const baseR = size * 0.3;
const outLen = size * 0.16;
const inLen = size * 0.055;
const half = Math.floor(barCount / 2);
const freq = new Uint8Array(256);
const TAU = Math.PI * 2;
let raf = 0;
let t = 0;
let energy = 0;
const draw = () => {
t += 0.016;
const palette = readPalette(canvas);
const dark = isDarkTheme();
const palette = adaptPalette(readPalette(canvas), dark);
const { sky, lav } = palette;
const node = analyserRef.current;
if (node) node.getByteFrequencyData(freq);
let energy = 0;
const breathe = 0.5 + 0.5 * Math.sin(t * 1.1);
let sum = 0;
for (let i = 0; i < barCount; i++) {
// 左右镜像m 从顶部 0 到底部 1再原路返回
const m = (i < half ? i : barCount - i) / half;
let target: number;
if (node) {
// 取低中频段(人声主能量),映射到一圈
const bin = Math.floor((i / barCount) * (freq.length * 0.62));
target = freq[bin] / 255;
// 低频朝上、高频朝下,幂映射拉开低频细节
const bin = Math.floor(Math.pow(m, 1.6) * freq.length * 0.7);
target = Math.pow(freq[bin] / 255, 1.25);
} else {
// 静态呼吸
target = 0.08 + 0.05 * (0.5 + 0.5 * Math.sin(t * 1.5 + i * 0.4));
// 静态呼吸 + 沿圆周缓慢游走的涟漪
target =
0.07 +
0.05 * breathe +
0.05 * (0.5 + 0.5 * Math.sin(m * 8.0 - t * 1.8));
}
smooth[i] += (target - smooth[i]) * 0.22;
energy += smooth[i];
const k = target > smooth[i] ? 0.35 : 0.12;
smooth[i] += (target - smooth[i]) * k;
sum += smooth[i];
}
energy /= barCount;
energy += (sum / barCount - energy) * 0.1;
ctx.clearRect(0, 0, size, size);
// 中心柔光:和光环模式一致的呼吸光晕
const breathe = 0.5 + 0.5 * Math.sin(t * 1.3);
const glowR = innerR * (1 + energy * 0.4) + size * 0.04 * breathe;
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR + maxBar);
glow.addColorStop(0, rgba(sky, 0.32 + energy * 0.4));
glow.addColorStop(0.5, rgba(lav, 0.12 + energy * 0.18));
// 中心柔光:与其他模式一致的呼吸光晕
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);
// 径向光柱:圆头、细、带柔光,缓慢旋转
const rotation = t * 0.08;
// 基准环:一条贯穿所有光柱的发丝细环
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.5, size * 0.008);
ctx.lineWidth = Math.max(1.3, size * 0.007);
for (let i = 0; i < barCount; i++) {
const angle = (i / barCount) * Math.PI * 2 + rotation;
const p = i / barCount;
const angle = p * TAU + rotation;
const v = smooth[i];
const r0 = innerR + size * 0.012;
const r1 = r0 + maxBar * (0.12 + v);
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, i / barCount);
ctx.strokeStyle = rgba(color, 0.35 + v * 0.5);
ctx.shadowColor = rgba(color, 0.7);
ctx.shadowBlur = 6 + v * 14;
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;
ctx.beginPath();
ctx.moveTo(cx + cos * r0, cy + sin * r0);
ctx.lineTo(cx + cos * r1, cy + sin * r1);