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:
@@ -3,7 +3,12 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
|
||||
import { readPalette, type RGB } from "@/lib/visualizer-palette";
|
||||
import {
|
||||
adaptPalette,
|
||||
isDarkTheme,
|
||||
readPalette,
|
||||
type RGB,
|
||||
} from "@/lib/visualizer-palette";
|
||||
|
||||
export type AuraVisualizerProps = {
|
||||
/** 是否激活:true 时采集麦克风并随音量律动,false 时显示静态呼吸态 */
|
||||
@@ -26,8 +31,9 @@ void main() {
|
||||
}
|
||||
`;
|
||||
|
||||
// 光环本体:极坐标下一个被 fbm 噪声轻微扰动边缘的柔光环 + 中心柔光,
|
||||
// 由 u_volume(音量)驱动缩放 / 亮度。配色随半径在三色间平滑过渡。
|
||||
// 虹彩光环:一圈被 fbm 噪声轻微扰动、沿圆周流动三色虹彩的发光细环,
|
||||
// 环内漂浮两团缓慢游走的柔光“流体”。静态时缓慢呼吸,
|
||||
// 激活后由 u_volume 驱动半径、亮度与扰动幅度。
|
||||
const FRAG = `
|
||||
precision highp float;
|
||||
|
||||
@@ -61,7 +67,7 @@ float fbm(vec2 p) {
|
||||
float v = 0.0;
|
||||
float a = 0.5;
|
||||
mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
v += a * noise(p);
|
||||
p = m * p;
|
||||
a *= 0.5;
|
||||
@@ -69,6 +75,14 @@ float fbm(vec2 p) {
|
||||
return v;
|
||||
}
|
||||
|
||||
// 三色沿相位做环形虹彩插值(cos 加权,首尾无缝)
|
||||
vec3 iridescent(float a) {
|
||||
float w0 = 0.5 + 0.5 * cos(a);
|
||||
float w1 = 0.5 + 0.5 * cos(a - 2.0943951);
|
||||
float w2 = 0.5 + 0.5 * cos(a - 4.1887902);
|
||||
return (u_c0 * w0 + u_c1 * w1 + u_c2 * w2) / (w0 + w1 + w2);
|
||||
}
|
||||
|
||||
// 静态抖动,消除平滑渐变上的色带
|
||||
float dither(vec2 p) {
|
||||
return (hash(p) - 0.5) / 255.0;
|
||||
@@ -80,39 +94,57 @@ void main() {
|
||||
float ang = atan(uv.y, uv.x);
|
||||
|
||||
float vol = clamp(u_volume, 0.0, 1.0) * u_active;
|
||||
float breathe = 0.5 + 0.5 * sin(u_time * 0.8);
|
||||
float breathe = 0.5 + 0.5 * sin(u_time * 0.7);
|
||||
|
||||
// 单层缓慢扰动的有机边缘
|
||||
float n = fbm(vec2(cos(ang), sin(ang)) * 1.6 + u_time * 0.12);
|
||||
float baseR = 0.32 + 0.03 * breathe + 0.09 * vol;
|
||||
float edge = baseR + (n - 0.5) * (0.03 + 0.05 * vol);
|
||||
float dr = r - edge;
|
||||
// 基础半径:静态缓慢呼吸,说话时随音量扩张
|
||||
float radius = 0.30 + 0.02 * breathe + 0.085 * vol;
|
||||
|
||||
float ring = exp(-dr * dr * 120.0); // 柔和发光环
|
||||
float halo = exp(-r * r * 5.0); // 中心柔光
|
||||
// 亮色模式削弱中心光晕,避免在白底上铺成灰雾
|
||||
float intensity = ring * (0.9 + 0.6 * vol)
|
||||
+ halo * (mix(0.28, 0.12, u_theme) + 0.4 * vol);
|
||||
// 双层噪声扰动出有机的环形轮廓
|
||||
vec2 dir = vec2(cos(ang), sin(ang));
|
||||
float n1 = fbm(dir * 1.7 + u_time * 0.14);
|
||||
float n2 = fbm(dir * 3.1 - u_time * 0.09 + 4.7);
|
||||
float rr = radius
|
||||
+ (n1 - 0.5) * (0.028 + 0.07 * vol)
|
||||
+ (n2 - 0.5) * (0.012 + 0.035 * vol);
|
||||
float d = r - rr;
|
||||
|
||||
// 平滑的径向配色,不随时间闪烁
|
||||
vec3 col = mix(u_c0, u_c1, smoothstep(0.0, 0.55, r));
|
||||
col = mix(col, u_c2, smoothstep(0.4, 0.95, r));
|
||||
// 细亮的环芯 + 宽柔的辉光晕
|
||||
float coreW = 0.008 + 0.005 * vol + 0.002 * breathe;
|
||||
float core = exp(-d * d / (2.0 * coreW * coreW));
|
||||
float bloomW = 0.055 + 0.055 * vol;
|
||||
float bloom = exp(-d * d / (2.0 * bloomW * bloomW)) * 0.55;
|
||||
|
||||
// 亮色 token 偏浅:提升饱和并适度加深,使颜色在浅背景上不发灰
|
||||
float luma = dot(col, vec3(0.299, 0.587, 0.114));
|
||||
col = clamp(mix(vec3(luma), col, mix(1.2, 1.65, u_theme)), 0.0, 1.0);
|
||||
col *= mix(1.0, 0.72, u_theme);
|
||||
// 环内流体:两团缓慢游走的柔光
|
||||
vec2 p1 = 0.13 * vec2(cos(u_time * 0.41), sin(u_time * 0.33));
|
||||
vec2 p2 = 0.16 * vec2(cos(-u_time * 0.26 + 2.3), sin(u_time * 0.36 + 1.2));
|
||||
vec2 q1 = uv - p1;
|
||||
vec2 q2 = uv - p2;
|
||||
float b1 = exp(-dot(q1, q1) / 0.022);
|
||||
float b2 = exp(-dot(q2, q2) / 0.030);
|
||||
float inside = smoothstep(rr + 0.01, rr - 0.07, r);
|
||||
float neb = (0.8 * b1 + 0.65 * b2) * inside
|
||||
* (0.22 + 0.10 * breathe + 0.55 * vol);
|
||||
|
||||
float bright = 0.85 + vol + 0.12 * breathe;
|
||||
vec3 hdr = col * intensity * bright;
|
||||
// 虹彩相位:沿圆周流动并被噪声轻微扭曲
|
||||
float flow = ang + u_time * 0.25 + (n1 - 0.5) * 2.2;
|
||||
vec3 ringCol = iridescent(flow);
|
||||
vec3 nebCol = mix(
|
||||
iridescent(u_time * 0.17 + 1.3),
|
||||
iridescent(-u_time * 0.11 + 3.9),
|
||||
0.5 + 0.5 * sin(u_time * 0.21)
|
||||
);
|
||||
|
||||
// 暗色:辉光优雅泛白;亮色:保持饱和色,不向白过曝
|
||||
vec3 darkMap = vec3(1.0) - exp(-hdr * 1.5);
|
||||
vec3 lightMap = col * clamp(intensity * bright, 0.0, 1.0);
|
||||
float ringI = core * (0.9 + 0.85 * vol + 0.1 * breathe)
|
||||
+ bloom * (0.35 + 0.6 * vol + 0.08 * breathe);
|
||||
vec3 hdr = ringCol * ringI + nebCol * neb;
|
||||
|
||||
// 暗色:高光优雅泛白;亮色:保持饱和色,不向白过曝
|
||||
vec3 darkMap = vec3(1.0) - exp(-hdr * 1.55);
|
||||
vec3 lightMap = clamp(ringCol * min(ringI, 1.0) + nebCol * neb, 0.0, 1.0);
|
||||
vec3 mapped = mix(darkMap, lightMap, u_theme);
|
||||
mapped += dither(gl_FragCoord.xy);
|
||||
|
||||
float alpha = clamp(intensity * mix(1.1, 1.4, u_theme), 0.0, 1.0);
|
||||
float alpha = clamp((ringI + neb) * mix(1.15, 1.35, u_theme), 0.0, 1.0);
|
||||
gl_FragColor = vec4(mapped, alpha);
|
||||
}
|
||||
`;
|
||||
@@ -209,7 +241,7 @@ export function AuraVisualizer({
|
||||
const draw = () => {
|
||||
const t = (performance.now() - start) / 1000;
|
||||
|
||||
// 由频谱算出单一音量标量(低中频,人声主能量),再平滑
|
||||
// 由频谱算出单一音量标量(低中频,人声主能量),快攻慢放地平滑
|
||||
const node = analyserRef.current;
|
||||
let target = 0;
|
||||
if (node) {
|
||||
@@ -219,16 +251,15 @@ export function AuraVisualizer({
|
||||
for (let i = 0; i < bins; i++) sum += freq[i];
|
||||
target = sum / bins / 255;
|
||||
}
|
||||
volumeRef.current += (target - volumeRef.current) * 0.18;
|
||||
const k = target > volumeRef.current ? 0.3 : 0.08;
|
||||
volumeRef.current += (target - volumeRef.current) * k;
|
||||
|
||||
const { sky, lav, rose } = readPalette(canvas);
|
||||
const light = document.documentElement.classList.contains("dark")
|
||||
? 0
|
||||
: 1;
|
||||
const dark = isDarkTheme();
|
||||
const { sky, lav, rose } = adaptPalette(readPalette(canvas), dark);
|
||||
gl.uniform1f(uTime, t);
|
||||
gl.uniform1f(uVol, volumeRef.current);
|
||||
gl.uniform1f(uActive, activeRef.current ? 1 : 0);
|
||||
gl.uniform1f(uTheme, light);
|
||||
gl.uniform1f(uTheme, dark ? 0 : 1);
|
||||
gl.uniform3fv(uC0, norm(sky));
|
||||
gl.uniform3fv(uC1, norm(lav));
|
||||
gl.uniform3fv(uC2, norm(rose));
|
||||
|
||||
181
frontend/src/components/ui/nebula-visualizer.tsx
Normal file
181
frontend/src/components/ui/nebula-visualizer.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
|
||||
import { readPalette, rgba } from "@/lib/visualizer-palette";
|
||||
import {
|
||||
adaptPalette,
|
||||
isDarkTheme,
|
||||
readPalette,
|
||||
rgba,
|
||||
} from "@/lib/visualizer-palette";
|
||||
|
||||
export type WaveVisualizerProps = {
|
||||
/** 是否激活:true 时采集麦克风并随波形起伏,false 时显示静态呼吸态 */
|
||||
@@ -66,7 +71,7 @@ export function WaveVisualizer({
|
||||
|
||||
const draw = () => {
|
||||
t += 0.016;
|
||||
const { sky, lav, rose } = readPalette(canvas);
|
||||
const { sky, lav, rose } = adaptPalette(readPalette(canvas), isDarkTheme());
|
||||
|
||||
const node = analyserRef.current;
|
||||
let level = 0;
|
||||
|
||||
Reference in New Issue
Block a user