From 9aea5d2f7df775b159ced5ea4d216a37cd3396a8 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Mon, 8 Jun 2026 07:51:45 +0800 Subject: [PATCH] Implement voice visualization feature in AssistantPage with microphone control Added a new VoiceVisualizer component to the AssistantPage, enabling real-time audio visualization during voice testing. Integrated microphone control with state management for recording status and error handling. Updated UI elements to reflect recording state and provide user feedback on microphone access issues, enhancing the overall user experience for voice interactions. --- src/components/pages/AssistantPage.tsx | 49 ++++- src/components/ui/voice-visualizer.tsx | 270 +++++++++++++++++++++++++ 2 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 src/components/ui/voice-visualizer.tsx 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 ( + + ); +}