diff --git a/frontend/src/components/ui/aura-visualizer.tsx b/frontend/src/components/ui/aura-visualizer.tsx index 8379617..f9565ad 100644 --- a/frontend/src/components/ui/aura-visualizer.tsx +++ b/frontend/src/components/ui/aura-visualizer.tsx @@ -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)); diff --git a/frontend/src/components/ui/nebula-visualizer.tsx b/frontend/src/components/ui/nebula-visualizer.tsx new file mode 100644 index 0000000..58b9fbb --- /dev/null +++ b/frontend/src/components/ui/nebula-visualizer.tsx @@ -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(null); + const particlesRef = React.useRef([]); + 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 ( + + ); +} diff --git a/frontend/src/components/ui/spectrum-visualizer.tsx b/frontend/src/components/ui/spectrum-visualizer.tsx index 2c45446..15bd4a4 100644 --- a/frontend/src/components/ui/spectrum-visualizer.tsx +++ b/frontend/src/components/ui/spectrum-visualizer.tsx @@ -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); diff --git a/frontend/src/components/ui/wave-visualizer.tsx b/frontend/src/components/ui/wave-visualizer.tsx index 49eb300..eaa233e 100644 --- a/frontend/src/components/ui/wave-visualizer.tsx +++ b/frontend/src/components/ui/wave-visualizer.tsx @@ -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;