Files
ai-video-fullstack/frontend/src/components/ui/aura-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

294 lines
8.7 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,
isDarkTheme,
readPalette,
type RGB,
} from "@/lib/visualizer-palette";
export type AuraVisualizerProps = {
/** 是否激活true 时采集麦克风并随音量律动false 时显示静态呼吸态 */
active?: boolean;
/** 外部分析器;提供后组件不再自行申请麦克风 */
analyser?: AnalyserNode | null;
/** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */
stream?: MediaStream | null;
/** 画布直径px */
size?: number;
/** 申请麦克风失败时回调 */
onError?: (error: unknown) => void;
className?: string;
};
const VERT = `
attribute vec2 a_pos;
void main() {
gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;
// 虹彩光环:一圈被 fbm 噪声轻微扰动、沿圆周流动三色虹彩的发光细环,
// 环内漂浮两团缓慢游走的柔光“流体”。静态时缓慢呼吸,
// 激活后由 u_volume 驱动半径、亮度与扰动幅度。
const FRAG = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_volume; // 0~1已平滑
uniform float u_active; // 0 静态 / 1 激活
uniform float u_theme; // 0 暗色 / 1 亮色
uniform vec3 u_c0; // sky
uniform vec3 u_c1; // lavender
uniform vec3 u_c2; // rose
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
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 < 4; i++) {
v += a * noise(p);
p = m * p;
a *= 0.5;
}
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;
}
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y);
float r = length(uv);
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.7);
// 基础半径:静态缓慢呼吸,说话时随音量扩张
float radius = 0.30 + 0.02 * breathe + 0.085 * 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;
// 细亮的环芯 + 宽柔的辉光晕
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;
// 环内流体:两团缓慢游走的柔光
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 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)
);
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((ringI + neb) * mix(1.15, 1.35, u_theme), 0.0, 1.0);
gl_FragColor = vec4(mapped, alpha);
}
`;
function compile(gl: WebGLRenderingContext, type: number, src: string) {
const sh = gl.createShader(type);
if (!sh) return null;
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
gl.deleteShader(sh);
return null;
}
return sh;
}
const norm = ({ r, g, b }: RGB): [number, number, number] => [
r / 255,
g / 255,
b / 255,
];
export function AuraVisualizer({
active = false,
analyser = null,
stream = null,
size = 220,
onError,
className,
}: AuraVisualizerProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const volumeRef = React.useRef(0);
const activeRef = React.useRef(active);
const analyserRef = useAudioAnalyser({ active, analyser, stream, onError });
React.useEffect(() => {
activeRef.current = active;
}, [active]);
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl", {
alpha: true,
premultipliedAlpha: false,
antialias: true,
});
if (!gl) return;
const vs = compile(gl, gl.VERTEX_SHADER, VERT);
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
const prog = gl.createProgram();
if (!vs || !fs || !prog) return;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return;
gl.useProgram(prog);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const aPos = gl.getAttribLocation(prog, "a_pos");
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
const uRes = gl.getUniformLocation(prog, "u_resolution");
const uTime = gl.getUniformLocation(prog, "u_time");
const uVol = gl.getUniformLocation(prog, "u_volume");
const uActive = gl.getUniformLocation(prog, "u_active");
const uTheme = gl.getUniformLocation(prog, "u_theme");
const uC0 = gl.getUniformLocation(prog, "u_c0");
const uC1 = gl.getUniformLocation(prog, "u_c1");
const uC2 = gl.getUniformLocation(prog, "u_c2");
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const px = Math.round(size * dpr);
canvas.width = px;
canvas.height = px;
gl.viewport(0, 0, px, px);
gl.uniform2f(uRes, px, px);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
const freq = new Uint8Array(256);
let raf = 0;
const start = performance.now();
const draw = () => {
const t = (performance.now() - start) / 1000;
// 由频谱算出单一音量标量(低中频,人声主能量),快攻慢放地平滑
const node = analyserRef.current;
let target = 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];
target = sum / bins / 255;
}
const k = target > volumeRef.current ? 0.3 : 0.08;
volumeRef.current += (target - volumeRef.current) * k;
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, dark ? 0 : 1);
gl.uniform3fv(uC0, norm(sky));
gl.uniform3fv(uC1, norm(lav));
gl.uniform3fv(uC2, norm(rose));
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 6);
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf);
gl.deleteProgram(prog);
gl.deleteShader(vs);
gl.deleteShader(fs);
gl.deleteBuffer(buf);
};
}, [size, analyserRef]);
return (
<canvas
ref={canvasRef}
role="img"
aria-label="麦克风音频可视化(光环)"
style={{ width: size, height: size }}
className={cn("select-none", className)}
/>
);
}