- 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.
294 lines
8.7 KiB
TypeScript
294 lines
8.7 KiB
TypeScript
"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)}
|
||
/>
|
||
);
|
||
}
|