"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(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 ( ); }