diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx index 075a9e8..e3fe98e 100644 --- a/frontend/src/components/pages/AssistantPage.tsx +++ b/frontend/src/components/pages/AssistantPage.tsx @@ -29,6 +29,8 @@ import { Terminal, Loader2, PhoneOff, + Orbit, + Waves, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -54,7 +56,9 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { VoiceVisualizer } from "@/components/ui/voice-visualizer"; +import { AuraVisualizer } from "@/components/ui/aura-visualizer"; +import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer"; +import { WaveVisualizer } from "@/components/ui/wave-visualizer"; import { Card, CardContent, @@ -1662,32 +1666,76 @@ export function AssistantPage() { ); } +type VizStyle = "aura" | "bars" | "wave"; + +const VIZ_ORDER: VizStyle[] = ["aura", "bars", "wave"]; +const VIZ_LABEL: Record = { + aura: "光环", + bars: "频谱", + wave: "波形", +}; + function DebugDrawer() { const [showTranscript, setShowTranscript] = useState(false); + const [vizStyle, setVizStyle] = useState("wave"); return ( ); } -function DebugVoicePanel({ showTranscript }: { showTranscript: boolean }) { +function DebugVoicePanel({ + showTranscript, + vizStyle, +}: { + showTranscript: boolean; + vizStyle: VizStyle; +}) { const [recording, setRecording] = useState(false); const [micError, setMicError] = useState(false); @@ -1696,7 +1744,7 @@ function DebugVoicePanel({ showTranscript }: { showTranscript: boolean }) { {showTranscript ? ( ) : ( -
+
- { - setMicError(true); - setRecording(false); - }} - /> +
+ {(() => { + const onVizError = () => { + setMicError(true); + setRecording(false); + }; + const shared = { + active: recording, + className: "relative shrink-0", + onError: onVizError, + } as const; + if (vizStyle === "aura") + return ; + if (vizStyle === "bars") + return ( + + ); + return ; + })()} +
-
-
+
+
{recording ? "我在聆听" : "开始一次语音对话"}
-

+

{micError ? "无法访问麦克风,请检查浏览器权限后重试。" : recording @@ -1742,24 +1800,22 @@ function DebugVoicePanel({ showTranscript }: { showTranscript: boolean }) {

-
- -
+
)} diff --git a/frontend/src/components/ui/aura-visualizer.tsx b/frontend/src/components/ui/aura-visualizer.tsx new file mode 100644 index 0000000..8379617 --- /dev/null +++ b/frontend/src/components/ui/aura-visualizer.tsx @@ -0,0 +1,262 @@ +"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 ( + + ); +} diff --git a/frontend/src/components/ui/spectrum-visualizer.tsx b/frontend/src/components/ui/spectrum-visualizer.tsx new file mode 100644 index 0000000..2c45446 --- /dev/null +++ b/frontend/src/components/ui/spectrum-visualizer.tsx @@ -0,0 +1,140 @@ +"use client"; + +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"; + +export type SpectrumVisualizerProps = { + /** 是否激活:true 时采集麦克风并随音量律动,false 时显示静态呼吸态 */ + active?: boolean; + /** 外部分析器;提供后组件不再自行申请麦克风 */ + analyser?: AnalyserNode | null; + /** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */ + stream?: MediaStream | null; + /** 画布直径(px) */ + size?: number; + /** 环绕的频谱柱数量 */ + barCount?: number; + /** 申请麦克风失败时回调 */ + onError?: (error: unknown) => void; + className?: string; +}; + +/** + * 径向频谱:一圈围绕中心柔光的细长光柱,随频谱起伏。 + * 克制、对称,与光环模式共用同一套调色与柔光语言。 + */ +export function SpectrumVisualizer({ + active = false, + analyser = null, + stream = null, + size = 220, + barCount = 64, + onError, + className, +}: SpectrumVisualizerProps) { + const canvasRef = React.useRef(null); + const smoothRef = React.useRef(new Float32Array(barCount)); + 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); + + if (smoothRef.current.length !== barCount) { + smoothRef.current = new Float32Array(barCount); + } + const smooth = smoothRef.current; + + const cx = size / 2; + const cy = size / 2; + const innerR = size * 0.22; + const maxBar = size * 0.2; + const freq = new Uint8Array(256); + + let raf = 0; + let t = 0; + + const draw = () => { + t += 0.016; + const palette = readPalette(canvas); + const { sky, lav } = palette; + + const node = analyserRef.current; + if (node) node.getByteFrequencyData(freq); + + let energy = 0; + for (let i = 0; i < barCount; i++) { + let target: number; + if (node) { + // 取低中频段(人声主能量),映射到一圈 + const bin = Math.floor((i / barCount) * (freq.length * 0.62)); + target = freq[bin] / 255; + } else { + // 静态呼吸态 + target = 0.08 + 0.05 * (0.5 + 0.5 * Math.sin(t * 1.5 + i * 0.4)); + } + smooth[i] += (target - smooth[i]) * 0.22; + energy += smooth[i]; + } + energy /= barCount; + + 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)); + glow.addColorStop(1, rgba(lav, 0)); + ctx.fillStyle = glow; + ctx.fillRect(0, 0, size, size); + + // 径向光柱:圆头、细、带柔光,缓慢旋转 + const rotation = t * 0.08; + ctx.lineCap = "round"; + ctx.lineWidth = Math.max(1.5, size * 0.008); + for (let i = 0; i < barCount; i++) { + const angle = (i / barCount) * Math.PI * 2 + rotation; + const v = smooth[i]; + const r0 = innerR + size * 0.012; + const r1 = r0 + maxBar * (0.12 + 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; + ctx.beginPath(); + ctx.moveTo(cx + cos * r0, cy + sin * r0); + ctx.lineTo(cx + cos * r1, cy + sin * r1); + ctx.stroke(); + } + ctx.shadowBlur = 0; + + raf = requestAnimationFrame(draw); + }; + + raf = requestAnimationFrame(draw); + return () => cancelAnimationFrame(raf); + }, [size, barCount, analyserRef]); + + return ( + + ); +} diff --git a/frontend/src/components/ui/voice-visualizer.tsx b/frontend/src/components/ui/voice-visualizer.tsx deleted file mode 100644 index ab7498e..0000000 --- a/frontend/src/components/ui/voice-visualizer.tsx +++ /dev/null @@ -1,351 +0,0 @@ -"use client"; - -import * as React from "react"; -import { cn } from "@/lib/utils"; - -export type VoiceVisualizerProps = { - /** 是否激活:true 时采集麦克风并随音量律动,false 时显示静态呼吸态 */ - active?: boolean; - /** 外部分析器;提供后组件不再自行申请麦克风(便于复用现有音频链路) */ - analyser?: AnalyserNode | null; - /** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */ - stream?: MediaStream | null; - /** 画布直径(px) */ - size?: number; - /** 环绕的频谱柱数量 */ - barCount?: number; - /** 申请麦克风失败时回调 */ - onError?: (error: unknown) => void; - className?: string; -}; - -type RGB = { r: number; g: number; b: number }; - -function parseHex(hex: string, fallback: RGB): RGB { - const m = hex.trim().replace("#", ""); - if (m.length === 6) { - return { - r: parseInt(m.slice(0, 2), 16), - g: parseInt(m.slice(2, 4), 16), - b: parseInt(m.slice(4, 6), 16), - }; - } - if (m.length === 3) { - return { - r: parseInt(m[0] + m[0], 16), - g: parseInt(m[1] + m[1], 16), - b: parseInt(m[2] + m[2], 16), - }; - } - return fallback; -} - -function rgba({ r, g, b }: RGB, a: number) { - return `rgba(${r}, ${g}, ${b}, ${a})`; -} - -function mix(a: RGB, b: RGB, t: number): RGB { - return { - r: Math.round(a.r + (b.r - a.r) * t), - g: Math.round(a.g + (b.g - a.g) * t), - b: Math.round(a.b + (b.b - a.b) * t), - }; -} - -/** 在一组首尾相连的颜色之间取环形插值 */ -function cyclicColor(stops: RGB[], p: number): RGB { - const n = stops.length; - const scaled = (p % 1) * n; - const i = Math.floor(scaled); - return mix(stops[i % n], stops[(i + 1) % n], scaled - i); -} - -export function VoiceVisualizer({ - active = false, - analyser = null, - stream = null, - size = 200, - barCount = 72, - onError, - className, -}: VoiceVisualizerProps) { - const canvasRef = React.useRef(null); - const analyserRef = React.useRef(null); - const smoothRef = React.useRef(new Float32Array(barCount)); - const onErrorRef = React.useRef(onError); - React.useEffect(() => { - onErrorRef.current = onError; - }, [onError]); - - // 音频接入:优先用外部 analyser / stream,否则在 active 时申请麦克风 - React.useEffect(() => { - let cancelled = false; - let ctx: AudioContext | null = null; - let micStream: MediaStream | null = null; - - async function setup() { - if (analyser) { - analyserRef.current = analyser; - return; - } - if (!active) { - analyserRef.current = null; - return; - } - try { - const AudioCtor = - window.AudioContext || - (window as unknown as { webkitAudioContext: typeof AudioContext }) - .webkitAudioContext; - ctx = new AudioCtor(); - - let source: MediaStreamAudioSourceNode; - if (stream) { - source = ctx.createMediaStreamSource(stream); - } else { - micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); - if (cancelled) { - micStream.getTracks().forEach((track) => track.stop()); - return; - } - source = ctx.createMediaStreamSource(micStream); - } - - const node = ctx.createAnalyser(); - node.fftSize = 256; - node.smoothingTimeConstant = 0.82; - source.connect(node); - analyserRef.current = node; - } catch (error) { - if (!cancelled) onErrorRef.current?.(error); - } - } - - setup(); - - return () => { - cancelled = true; - analyserRef.current = analyser ?? null; - micStream?.getTracks().forEach((track) => track.stop()); - ctx?.close().catch(() => {}); - }; - }, [active, analyser, stream]); - - // 绘制循环 - 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); - - if (smoothRef.current.length !== barCount) { - smoothRef.current = new Float32Array(barCount); - } - const smooth = smoothRef.current; - - const cx = size / 2; - const cy = size / 2; - const orbR = size * 0.18; - const maxBar = size * 0.15; - const freq = new Uint8Array(128); - - let raf = 0; - let t = 0; - - const draw = () => { - t += 0.016; - - const fallbackSky = { r: 95, g: 134, b: 184 }; - const fallbackLav = { r: 138, g: 120, b: 173 }; - const fallbackRose = { r: 176, g: 125, b: 140 }; - const style = getComputedStyle(canvas); - const sky = parseHex( - style.getPropertyValue("--gradient-sky"), - fallbackSky, - ); - const lav = parseHex( - style.getPropertyValue("--gradient-lavender"), - fallbackLav, - ); - const rose = parseHex( - style.getPropertyValue("--gradient-rose"), - fallbackRose, - ); - const palette = [sky, lav, rose]; - - const node = analyserRef.current; - if (node) { - node.getByteFrequencyData(freq); - } - - let energy = 0; - for (let i = 0; i < barCount; i++) { - let target: number; - if (node) { - // 取低中频段(人声主要能量)映射到环形 - const bin = Math.floor((i / barCount) * (freq.length * 0.7)); - target = freq[bin] / 255; - } else { - // 静态呼吸态 - target = 0.1 + 0.06 * (0.5 + 0.5 * Math.sin(t * 1.6 + i * 0.35)); - } - smooth[i] += (target - smooth[i]) * 0.28; - energy += smooth[i]; - } - energy /= barCount; - - ctx.clearRect(0, 0, size, size); - const breathe = 0.5 + 0.5 * Math.sin(t * 1.4); - const pulse = 1 + energy * 0.2 + breathe * 0.035; - - // 整体环境光,让频谱像悬浮在空间中。 - const glow = ctx.createRadialGradient( - cx, - cy, - orbR * 0.1, - cx, - cy, - size * 0.5, - ); - glow.addColorStop(0, rgba(sky, 0.24 + energy * 0.42)); - glow.addColorStop(0.38, rgba(lav, 0.12 + energy * 0.2)); - glow.addColorStop(0.72, rgba(rose, 0.04 + energy * 0.08)); - glow.addColorStop(1, rgba(lav, 0)); - ctx.fillStyle = glow; - ctx.fillRect(0, 0, size, size); - - // 远景漂浮粒子,旋转速度很慢,提供空间纵深。 - for (let i = 0; i < 18; i++) { - const phase = i * 2.399 + t * (0.035 + (i % 4) * 0.008); - const radius = size * (0.31 + (i % 6) * 0.026); - const drift = Math.sin(t * 0.45 + i * 1.7) * size * 0.018; - const x = cx + Math.cos(phase) * (radius + drift); - const y = cy + Math.sin(phase) * (radius + drift); - const color = cyclicColor(palette, i / 18); - const particleR = size * (0.004 + (i % 3) * 0.002); - ctx.beginPath(); - ctx.arc(x, y, particleR, 0, Math.PI * 2); - ctx.fillStyle = rgba(color, 0.13 + energy * 0.28); - ctx.shadowColor = rgba(color, 0.55); - ctx.shadowBlur = 5 + energy * 10; - ctx.fill(); - } - ctx.shadowBlur = 0; - - // 两条不完整的轨道弧光,制造环绕与旋转感。 - ctx.lineWidth = Math.max(0.8, size * 0.005); - ctx.lineCap = "round"; - for (let i = 0; i < 2; i++) { - const radius = size * (0.32 + i * 0.07); - const start = t * (i === 0 ? 0.12 : -0.08) + i * Math.PI; - const arc = Math.PI * (0.55 + i * 0.18); - const orbit = ctx.createLinearGradient( - cx - radius, - cy, - cx + radius, - cy, - ); - orbit.addColorStop(0, rgba(lav, 0)); - orbit.addColorStop(0.5, rgba(i === 0 ? sky : rose, 0.22 + energy * 0.3)); - orbit.addColorStop(1, rgba(lav, 0)); - ctx.strokeStyle = orbit; - ctx.shadowColor = rgba(i === 0 ? sky : rose, 0.4); - ctx.shadowBlur = 7; - ctx.beginPath(); - ctx.arc(cx, cy, radius, start, start + arc); - ctx.stroke(); - } - ctx.shadowBlur = 0; - - // 外层频谱环:更细、更轻,负责表现扩散。 - const rotation = t * 0.12; - ctx.lineCap = "round"; - ctx.lineWidth = Math.max(1.2, size * 0.007); - for (let i = 0; i < barCount; i++) { - const angle = (i / barCount) * Math.PI * 2 + rotation; - const v = smooth[i]; - const r0 = size * 0.285 + energy * size * 0.025; - const r1 = r0 + maxBar * (0.2 + v * 0.95); - const cos = Math.cos(angle); - const sin = Math.sin(angle); - const color = cyclicColor(palette, i / barCount); - ctx.strokeStyle = rgba(color, 0.3 + v * 0.55); - ctx.shadowColor = rgba(color, 0.8); - ctx.shadowBlur = 5 + v * 18; - ctx.beginPath(); - ctx.moveTo(cx + cos * r0, cy + sin * r0); - ctx.lineTo(cx + cos * r1, cy + sin * r1); - ctx.stroke(); - } - ctx.shadowBlur = 0; - - // 内层频谱环:反向旋转,形成更有机的声场边缘。 - ctx.lineWidth = Math.max(1.5, size * 0.009); - for (let i = 0; i < barCount; i += 2) { - const angle = (i / barCount) * Math.PI * 2 - t * 0.09; - const v = smooth[(i + Math.floor(barCount / 3)) % barCount]; - const r0 = orbR * pulse + size * 0.018; - const r1 = r0 + size * (0.018 + v * 0.055); - const cos = Math.cos(angle); - const sin = Math.sin(angle); - const color = cyclicColor(palette, 1 - i / barCount); - ctx.strokeStyle = rgba(color, 0.38 + v * 0.5); - ctx.beginPath(); - ctx.moveTo(cx + cos * r0, cy + sin * r0); - ctx.lineTo(cx + cos * r1, cy + sin * r1); - ctx.stroke(); - } - - // 中心声场核心:深色边缘包住高亮中心,音量越高越通透。 - const coreR = orbR * pulse; - const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR); - core.addColorStop(0, rgba(sky, 0.42 + energy * 0.45)); - core.addColorStop(0.28, rgba(lav, 0.2 + energy * 0.28)); - core.addColorStop(0.68, rgba(rose, 0.08 + energy * 0.16)); - core.addColorStop(1, rgba(lav, 0.01)); - ctx.shadowColor = rgba(sky, 0.5 + energy * 0.35); - ctx.shadowBlur = 18 + energy * 35; - ctx.beginPath(); - ctx.arc(cx, cy, coreR, 0, Math.PI * 2); - ctx.fillStyle = core; - ctx.fill(); - ctx.shadowBlur = 0; - - // 核心表面的柔和高光,让它不只是一个平面渐变。 - const highlight = ctx.createRadialGradient( - cx - coreR * 0.28, - cy - coreR * 0.32, - 0, - cx - coreR * 0.15, - cy - coreR * 0.18, - coreR * 0.85, - ); - highlight.addColorStop(0, rgba(sky, 0.22 + energy * 0.22)); - highlight.addColorStop(1, rgba(sky, 0)); - ctx.beginPath(); - ctx.arc(cx, cy, coreR * 0.96, 0, Math.PI * 2); - ctx.fillStyle = highlight; - ctx.fill(); - - raf = requestAnimationFrame(draw); - }; - - raf = requestAnimationFrame(draw); - return () => cancelAnimationFrame(raf); - }, [size, barCount]); - - return ( - - ); -} diff --git a/frontend/src/components/ui/wave-visualizer.tsx b/frontend/src/components/ui/wave-visualizer.tsx new file mode 100644 index 0000000..49eb300 --- /dev/null +++ b/frontend/src/components/ui/wave-visualizer.tsx @@ -0,0 +1,157 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import { useAudioAnalyser } from "@/hooks/use-audio-analyser"; +import { readPalette, rgba } from "@/lib/visualizer-palette"; + +export type WaveVisualizerProps = { + /** 是否激活:true 时采集麦克风并随波形起伏,false 时显示静态呼吸态 */ + active?: boolean; + /** 外部分析器;提供后组件不再自行申请麦克风 */ + analyser?: AnalyserNode | null; + /** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */ + stream?: MediaStream | null; + /** 画布直径(px,方形画布,波形居中横贯) */ + size?: number; + /** 申请麦克风失败时回调 */ + onError?: (error: unknown) => void; + className?: string; +}; + +/** + * 水平波形:一条横贯中线的柔光曲线,随时域波形起伏。 + * 与光环 / 频谱共用调色与柔光语言,节奏同样克制。 + */ +export function WaveVisualizer({ + active = false, + analyser = null, + stream = null, + size = 220, + onError, + className, +}: WaveVisualizerProps) { + const canvasRef = React.useRef(null); + const analyserRef = useAudioAnalyser({ + active, + analyser, + stream, + onError, + smoothingTimeConstant: 0.6, + }); + + 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 cy = size / 2; + const pad = size * 0.1; + const span = size - pad * 2; + const steps = 96; + const time = new Uint8Array(2048); + + let raf = 0; + let t = 0; + let env = 0; // 平滑后的整体振幅包络 + + // 边缘淡出的窗函数,让波形两端自然收束 + const taper = (x: number) => Math.sin(Math.PI * x) ** 0.7; + + const draw = () => { + t += 0.016; + const { sky, lav, rose } = readPalette(canvas); + + const node = analyserRef.current; + let level = 0; + if (node) { + node.getByteTimeDomainData(time); + let sum = 0; + for (let i = 0; i < node.fftSize; i++) { + const d = (time[i] - 128) / 128; + sum += d * d; + } + level = Math.min(1, Math.sqrt(sum / node.fftSize) * 3.2); + } + env += (level - env) * 0.15; + + const breathe = 0.5 + 0.5 * Math.sin(t * 1.2); + const amp = size * (0.04 + 0.02 * breathe) + size * 0.26 * env; + + // 取一段波形样本(激活时用真实时域,否则用两层正弦合成) + const sample = (x: number) => { + if (node) { + const idx = Math.floor(x * (node.fftSize - 1)); + return (time[idx] - 128) / 128; + } + return ( + 0.6 * Math.sin(x * 6.2 + t * 2.0) + + 0.4 * Math.sin(x * 11.0 - t * 1.3) + ); + }; + + ctx.clearRect(0, 0, size, size); + + // 横向渐变描边:sky → lavender → rose + const grad = ctx.createLinearGradient(pad, 0, pad + span, 0); + grad.addColorStop(0, rgba(sky, 0)); + grad.addColorStop(0.2, rgba(sky, 0.85)); + grad.addColorStop(0.5, rgba(lav, 0.95)); + grad.addColorStop(0.8, rgba(rose, 0.85)); + grad.addColorStop(1, rgba(rose, 0)); + + const buildPath = (scale: number) => { + ctx.beginPath(); + for (let i = 0; i <= steps; i++) { + const p = i / steps; + const x = pad + p * span; + const y = cy + sample(p) * amp * scale * taper(p); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + }; + + // 底层:更宽更淡的回声,制造纵深 + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = grad; + ctx.globalAlpha = 0.25 + env * 0.2; + ctx.lineWidth = Math.max(2, size * 0.018); + ctx.shadowColor = rgba(lav, 0.6); + ctx.shadowBlur = 14 + env * 22; + buildPath(0.55); + ctx.stroke(); + + // 主波形:清晰的柔光主线 + ctx.globalAlpha = 1; + ctx.lineWidth = Math.max(1.5, size * 0.011); + ctx.shadowBlur = 8 + env * 14; + buildPath(1); + ctx.stroke(); + + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; + + raf = requestAnimationFrame(draw); + }; + + raf = requestAnimationFrame(draw); + return () => cancelAnimationFrame(raf); + }, [size, analyserRef]); + + return ( + + ); +} diff --git a/frontend/src/hooks/use-audio-analyser.ts b/frontend/src/hooks/use-audio-analyser.ts new file mode 100644 index 0000000..590fb46 --- /dev/null +++ b/frontend/src/hooks/use-audio-analyser.ts @@ -0,0 +1,90 @@ +"use client"; + +import * as React from "react"; + +export type UseAudioAnalyserOptions = { + /** 是否激活:true 时采集麦克风,false 时不连接(用于静态呼吸态) */ + active?: boolean; + /** 外部分析器;提供后直接复用,不再申请麦克风 */ + analyser?: AnalyserNode | null; + /** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */ + stream?: MediaStream | null; + fftSize?: number; + smoothingTimeConstant?: number; + onError?: (error: unknown) => void; +}; + +/** + * 三种可视化共用的音频接入逻辑:优先用外部 analyser / stream, + * 否则在 active 时申请麦克风。返回一个始终指向当前 AnalyserNode 的 ref。 + */ +export function useAudioAnalyser({ + active = false, + analyser = null, + stream = null, + fftSize = 512, + smoothingTimeConstant = 0.7, + onError, +}: UseAudioAnalyserOptions) { + const analyserRef = React.useRef(null); + const onErrorRef = React.useRef(onError); + + React.useEffect(() => { + onErrorRef.current = onError; + }, [onError]); + + React.useEffect(() => { + let cancelled = false; + let ctx: AudioContext | null = null; + let micStream: MediaStream | null = null; + + async function setup() { + if (analyser) { + analyserRef.current = analyser; + return; + } + if (!active) { + analyserRef.current = null; + return; + } + try { + const AudioCtor = + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext; + ctx = new AudioCtor(); + + let source: MediaStreamAudioSourceNode; + if (stream) { + source = ctx.createMediaStreamSource(stream); + } else { + micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (cancelled) { + micStream.getTracks().forEach((track) => track.stop()); + return; + } + source = ctx.createMediaStreamSource(micStream); + } + + const node = ctx.createAnalyser(); + node.fftSize = fftSize; + node.smoothingTimeConstant = smoothingTimeConstant; + source.connect(node); + analyserRef.current = node; + } catch (error) { + if (!cancelled) onErrorRef.current?.(error); + } + } + + setup(); + + return () => { + cancelled = true; + analyserRef.current = analyser ?? null; + micStream?.getTracks().forEach((track) => track.stop()); + ctx?.close().catch(() => {}); + }; + }, [active, analyser, stream, fftSize, smoothingTimeConstant]); + + return analyserRef; +} diff --git a/frontend/src/lib/visualizer-palette.ts b/frontend/src/lib/visualizer-palette.ts new file mode 100644 index 0000000..e7b2b2f --- /dev/null +++ b/frontend/src/lib/visualizer-palette.ts @@ -0,0 +1,62 @@ +// 可视化共用的配色工具:从设计 token(--gradient-*)读取调色板, +// 让三种可视化样式自动跟随明暗主题,并保持同一套视觉语言。 + +export type RGB = { r: number; g: number; b: number }; + +export type Palette = { sky: RGB; lav: RGB; rose: RGB }; + +/** 浅色主题下的回退值,避免首帧 token 未就绪时闪烁 */ +const FALLBACK: Palette = { + sky: { r: 168, g: 200, b: 232 }, + lav: { r: 200, g: 184, b: 224 }, + rose: { r: 232, g: 184, b: 196 }, +}; + +export function parseHex(hex: string, fallback: RGB): RGB { + const m = hex.trim().replace("#", ""); + if (m.length === 6) { + return { + r: parseInt(m.slice(0, 2), 16), + g: parseInt(m.slice(2, 4), 16), + b: parseInt(m.slice(4, 6), 16), + }; + } + if (m.length === 3) { + return { + r: parseInt(m[0] + m[0], 16), + g: parseInt(m[1] + m[1], 16), + b: parseInt(m[2] + m[2], 16), + }; + } + return fallback; +} + +export function rgba({ r, g, b }: RGB, a: number) { + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +export function mix(a: RGB, b: RGB, t: number): RGB { + return { + r: Math.round(a.r + (b.r - a.r) * t), + g: Math.round(a.g + (b.g - a.g) * t), + b: Math.round(a.b + (b.b - a.b) * t), + }; +} + +/** 在 sky → lavender → rose 三色之间取环形插值(首尾相连,适合沿圆周/横向铺色) */ +export function cyclicColor({ sky, lav, rose }: Palette, p: number): RGB { + const stops = [sky, lav, rose]; + const scaled = ((p % 1) + 1) % 1 * stops.length; + const i = Math.floor(scaled); + return mix(stops[i % stops.length], stops[(i + 1) % stops.length], scaled - i); +} + +/** 从元素上读取当前主题的 --gradient-* token,返回 0~255 的 RGB */ +export function readPalette(el: Element): Palette { + const s = getComputedStyle(el); + return { + sky: parseHex(s.getPropertyValue("--gradient-sky"), FALLBACK.sky), + lav: parseHex(s.getPropertyValue("--gradient-lavender"), FALLBACK.lav), + rose: parseHex(s.getPropertyValue("--gradient-rose"), FALLBACK.rose), + }; +}