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.
This commit is contained in:
Xin Wang
2026-06-08 07:51:45 +08:00
parent 82ca52d438
commit 9aea5d2f7d
2 changed files with 309 additions and 10 deletions

View File

@@ -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 (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 p-6 text-center">
<button
type="button"
className="flex h-24 w-24 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-transform hover:scale-105"
aria-label="开始语音测试"
>
<Mic size={32} />
</button>
<VoiceVisualizer
active={recording}
size={200}
onError={() => {
setMicError(true);
setRecording(false);
}}
/>
<div>
<div className="text-sm font-medium text-foreground"></div>
<div className="text-sm font-medium text-foreground">
{recording ? "正在聆听…" : "点击开始语音测试"}
</div>
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
{micError
? "无法访问麦克风,请检查浏览器权限后重试。"
: "点击下方按钮开始录音,实时识别结果将显示在下方。"}
</p>
</div>
<Button
onClick={() => {
setMicError(false);
setRecording((value) => !value);
}}
variant={recording ? "outline" : "default"}
className={[
"gap-2",
recording
? "border-hairline-strong text-muted-foreground hover:text-foreground"
: "",
].join(" ")}
>
{recording ? <Square size={16} /> : <Mic size={16} />}
{recording ? "结束录音" : "开始语音测试"}
</Button>
<div className="w-full rounded-xl border border-hairline bg-canvas-soft p-4 text-left">
<div className="caption-label mb-2 text-muted-soft"></div>
<p className="text-sm leading-6 text-muted-soft"></p>
<p className="text-sm leading-6 text-muted-soft">
{recording ? "聆听中,请开始说话…" : "等待语音输入…"}
</p>
</div>
</div>
);

View File

@@ -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<HTMLCanvasElement>(null);
const analyserRef = React.useRef<AnalyserNode | null>(null);
const smoothRef = React.useRef<Float32Array>(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 (
<canvas
ref={canvasRef}
role="img"
aria-label="麦克风音频可视化"
style={{ width: size, height: size }}
className={cn("select-none", className)}
/>
);
}