"use client"; 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"; 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 < 5; i++) { v += a * noise(p); p = m * p; a *= 0.5; } return v; } // 静态抖动,消除平滑渐变上的色带 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.8); // 单层缓慢扰动的有机边缘 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 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); // 平滑的径向配色,不随时间闪烁 vec3 col = mix(u_c0, u_c1, smoothstep(0.0, 0.55, r)); col = mix(col, u_c2, smoothstep(0.4, 0.95, r)); // 亮色 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); float bright = 0.85 + vol + 0.12 * breathe; vec3 hdr = col * intensity * bright; // 暗色:辉光优雅泛白;亮色:保持饱和色,不向白过曝 vec3 darkMap = vec3(1.0) - exp(-hdr * 1.5); vec3 lightMap = col * clamp(intensity * bright, 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); 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; } volumeRef.current += (target - volumeRef.current) * 0.18; const { sky, lav, rose } = readPalette(canvas); const light = document.documentElement.classList.contains("dark") ? 0 : 1; gl.uniform1f(uTime, t); gl.uniform1f(uVol, volumeRef.current); gl.uniform1f(uActive, activeRef.current ? 1 : 0); gl.uniform1f(uTheme, light); 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 ( ); }