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 (
+
+ );
+}