diff --git a/src/components/pages/AssistantPage.tsx b/src/components/pages/AssistantPage.tsx index bfc5e92..a0866ec 100644 --- a/src/components/pages/AssistantPage.tsx +++ b/src/components/pages/AssistantPage.tsx @@ -24,6 +24,7 @@ import { HelpCircle, Waypoints, AudioLines, + Square, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -49,6 +50,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { VoiceVisualizer } from "@/components/ui/voice-visualizer"; import { Card, CardContent, @@ -962,26 +964,53 @@ function DebugTextPanel() { } function DebugVoicePanel() { + const [recording, setRecording] = useState(false); + const [micError, setMicError] = useState(false); + return (
- + { + setMicError(true); + setRecording(false); + }} + />
-
点击开始语音测试
+
+ {recording ? "正在聆听…" : "点击开始语音测试"} +

- 点击麦克风后开始录音,实时识别结果将显示在下方。 + {micError + ? "无法访问麦克风,请检查浏览器权限后重试。" + : "点击下方按钮开始录音,实时识别结果将显示在下方。"}

+ +
实时转写
-

等待语音输入…

+

+ {recording ? "聆听中,请开始说话…" : "等待语音输入…"} +

); diff --git a/src/components/ui/voice-visualizer.tsx b/src/components/ui/voice-visualizer.tsx new file mode 100644 index 0000000..463c36d --- /dev/null +++ b/src/components/ui/voice-visualizer.tsx @@ -0,0 +1,270 @@ +"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.2; + const maxBar = size * 0.18; + 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.12 + breathe * 0.02; + + // 外层光晕 + const glow = ctx.createRadialGradient( + cx, + cy, + orbR * 0.3, + cx, + cy, + size * 0.5, + ); + glow.addColorStop(0, rgba(sky, 0.18 + energy * 0.35)); + glow.addColorStop(0.55, rgba(lav, 0.08 + energy * 0.18)); + glow.addColorStop(1, rgba(lav, 0)); + ctx.fillStyle = glow; + ctx.fillRect(0, 0, size, size); + + // 环形频谱柱 + ctx.lineCap = "round"; + ctx.lineWidth = Math.max(2, size * 0.012); + const rotation = t * 0.15; + for (let i = 0; i < barCount; i++) { + const angle = (i / barCount) * Math.PI * 2 + rotation; + const v = smooth[i]; + const r0 = orbR * pulse + size * 0.03; + const r1 = r0 + maxBar * v + 1.5; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const color = cyclicColor(palette, i / barCount); + ctx.strokeStyle = rgba(color, 0.55 + v * 0.4); + ctx.shadowColor = rgba(color, 0.8); + ctx.shadowBlur = 6 + v * 16; + ctx.beginPath(); + ctx.moveTo(cx + cos * r0, cy + sin * r0); + ctx.lineTo(cx + cos * r1, cy + sin * r1); + ctx.stroke(); + } + ctx.shadowBlur = 0; + + // 中心柔光:随音量发亮,不画实体球 + const coreR = orbR * pulse; + const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR); + core.addColorStop(0, rgba(sky, 0.22 + energy * 0.4)); + core.addColorStop(0.6, rgba(lav, 0.06 + energy * 0.15)); + core.addColorStop(1, rgba(lav, 0)); + ctx.beginPath(); + ctx.arc(cx, cy, coreR, 0, Math.PI * 2); + ctx.fillStyle = core; + ctx.fill(); + + raf = requestAnimationFrame(draw); + }; + + raf = requestAnimationFrame(draw); + return () => cancelAnimationFrame(raf); + }, [size, barCount]); + + return ( + + ); +}