组件
AI 语音波形 AI Voice Visualizer
专用于实时语音对话模式、语音输入与语音合成播放的动态音频电平声浪与光环可视化组件。
基础用法
展示三种核心声波形态(柱状均衡器 bars、呼吸光球 orb、正弦波形 wave)以及不同会话生命周期状态下的动态反馈:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/ai-voice-visualizer安装基础依赖与动效库
pnpm add motion class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/ai-voice-visualizer.tsx"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { useAnimationFrame, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
export type AiVoiceVisualizerState =
| "idle"
| "listening"
| "thinking"
| "speaking"
const aiVoiceVisualizerVariants = cva(
"group/voice inline-flex select-none items-center justify-center transition-colors duration-300 data-[state=idle]:text-muted-foreground/50 data-[state=listening]:text-info data-[state=speaking]:text-foreground data-[state=thinking]:text-foreground",
{
variants: {
size: {
sm: "h-6 min-w-6",
md: "h-10 min-w-10",
lg: "h-16 min-w-16",
},
},
defaultVariants: {
size: "md",
},
}
)
const stateLabels: Record<AiVoiceVisualizerState, string> = {
idle: "语音空闲",
listening: "正在聆听",
thinking: "正在思考",
speaking: "正在回复",
}
const waveWidths = { sm: "w-12", md: "w-24", lg: "w-36" } as const
export interface AiVoiceVisualizerProps
extends Omit<React.ComponentProps<"div">, "children">,
VariantProps<typeof aiVoiceVisualizerVariants> {
/** 可视化声波形态(柱状、呼吸光环或正弦曲线)。 @default "bars" */
variant?: "bars" | "orb" | "wave"
/** 语音会话当前所处的生命周期状态。 @default "idle" */
state?: AiVoiceVisualizerState
/** 实时音频电平归一化数值 [0.0 ~ 1.0]。省略时根据状态生成自然的模拟起伏。 */
audioLevel?: number
/** 柱状声波模式下的柱子数量。 @default 5 */
barCount?: number
/** 尺寸大小。 @default "md" */
size?: "sm" | "md" | "lg"
}
type Signal = {
state: AiVoiceVisualizerState
audioLevel?: number
}
const clamp = (value: number, min = 0, max = 1) =>
Math.min(Math.max(value, min), max)
/** Target energy for the current state, shared by every variant. */
function targetLevel({ state, audioLevel }: Signal, time: number, seed = 0) {
if (state === "idle") return 0
if (state === "thinking") return 0.35 + 0.2 * Math.sin(time * 2.6 + seed)
if (audioLevel !== undefined) return clamp(audioLevel)
return 0.45 + 0.22 * Math.sin(time * 2.1 + seed) + 0.1 * Math.sin(time * 5.3)
}
/**
* Real-time voice visualizer. Animation runs on the frame loop and writes
* styles directly, so frequent `audioLevel` updates never trigger re-render
* driven keyframe restarts and the motion stays continuous.
*/
function AiVoiceVisualizer({
className,
size = "md",
variant = "bars",
state = "idle",
audioLevel,
barCount = 5,
...props
}: AiVoiceVisualizerProps) {
const reduceMotion = !!useReducedMotion()
const signal = React.useRef<Signal>({ state, audioLevel })
signal.current = { state, audioLevel }
return (
<div
role="img"
aria-label={stateLabels[state]}
data-slot="ai-voice-visualizer"
data-state={state}
data-variant={variant}
className={cn(aiVoiceVisualizerVariants({ size }), className)}
{...props}
>
{variant === "bars" ? (
<BarsVisualizer
signal={signal}
snapshot={{ state, audioLevel }}
barCount={barCount}
reduceMotion={reduceMotion}
/>
) : variant === "orb" ? (
<OrbVisualizer
signal={signal}
snapshot={{ state, audioLevel }}
reduceMotion={reduceMotion}
/>
) : (
<WaveVisualizer
signal={signal}
snapshot={{ state, audioLevel }}
reduceMotion={reduceMotion}
widthClassName={waveWidths[size ?? "md"]}
/>
)}
</div>
)
}
function barShape(index: number, count: number) {
const center = (count - 1) / 2
return count > 1 ? 1 - Math.abs(index - center) / (center + 1) : 1
}
function staticBarHeight(signal: Signal, index: number, count: number) {
const level = targetLevel(signal, 0, index)
return 0.14 + level * (0.5 + 0.5 * barShape(index, count)) * 0.86
}
function BarsVisualizer({
signal,
snapshot,
barCount,
reduceMotion,
}: {
signal: React.RefObject<Signal>
snapshot: Signal
barCount: number
reduceMotion: boolean
}) {
const bars = React.useRef<Array<HTMLSpanElement | null>>([])
const heights = React.useRef<number[]>([])
useAnimationFrame((time) => {
if (reduceMotion) return
const t = time / 1000
const current = signal.current
for (let i = 0; i < barCount; i++) {
const bar = bars.current[i]
if (!bar) continue
let target: number
if (current.state === "thinking") {
// A travelling crest reads as "processing" rather than "hearing".
target = 0.16 + 0.62 * Math.max(0, Math.sin(t * 5 - i * 0.75)) ** 2
} else {
const level = targetLevel(current, t, i * 1.3)
const jitter = 0.7 + 0.3 * Math.sin(t * (7 + i * 1.9) + i * 2.1)
target = 0.14 + level * (0.45 + 0.55 * barShape(i, barCount)) * jitter
}
const previous = heights.current[i] ?? target
const next = previous + (clamp(target) - previous) * 0.22
heights.current[i] = next
bar.style.height = `${(next * 100).toFixed(2)}%`
}
})
return (
<div className="flex h-full items-center gap-[3px]">
{Array.from({ length: barCount }, (_, i) => (
<span
key={i}
ref={(node) => {
bars.current[i] = node
}}
className="block min-h-1 w-1 rounded-full bg-current"
style={{
height: `${(
(reduceMotion
? staticBarHeight(snapshot, i, barCount)
: 0.14) * 100
).toFixed(2)}%`,
}}
/>
))}
</div>
)
}
function OrbVisualizer({
signal,
snapshot,
reduceMotion,
}: {
signal: React.RefObject<Signal>
snapshot: Signal
reduceMotion: boolean
}) {
const core = React.useRef<HTMLSpanElement>(null)
const ring = React.useRef<HTMLSpanElement>(null)
const level = React.useRef(0)
useAnimationFrame((time) => {
if (reduceMotion || !core.current || !ring.current) return
const target = targetLevel(signal.current, time / 1000)
level.current += (target - level.current) * 0.12
const value = level.current
core.current.style.transform = `scale(${(0.78 + value * 0.22).toFixed(4)})`
ring.current.style.transform = `scale(${(0.86 + value * 0.3).toFixed(4)})`
ring.current.style.opacity = (0.12 + value * 0.4).toFixed(3)
})
const staticLevel = targetLevel(snapshot, 0)
return (
<div className="relative flex aspect-square h-full items-center justify-center">
<span
ref={ring}
aria-hidden
className="absolute inset-0 rounded-full border-2 border-current"
style={
reduceMotion
? {
transform: `scale(${0.86 + staticLevel * 0.3})`,
opacity: 0.12 + staticLevel * 0.4,
}
: { transform: "scale(0.86)", opacity: 0.12 }
}
/>
<span
aria-hidden
className="absolute inset-0 rounded-full border-2 border-transparent border-t-current opacity-0 transition-opacity duration-300 motion-safe:animate-spin [animation-duration:1.4s] group-data-[state=thinking]/voice:opacity-60"
/>
<span
ref={core}
aria-hidden
className="block size-3/5 rounded-full bg-current"
style={{
transform: `scale(${reduceMotion ? 0.78 + staticLevel * 0.22 : 0.78})`,
}}
/>
</div>
)
}
const WAVE_POINTS = 40
function wavePath(amplitude: number, phase: number, frequency: number) {
let d = ""
for (let i = 0; i <= WAVE_POINTS; i++) {
const x = (i / WAVE_POINTS) * 100
// Taper both ends so the line always rests on the baseline.
const envelope = Math.sin((Math.PI * i) / WAVE_POINTS)
const y =
15 +
amplitude * envelope * Math.sin((i / WAVE_POINTS) * Math.PI * 2 * frequency + phase)
d += `${i === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)} `
}
return d
}
function WaveVisualizer({
signal,
snapshot,
reduceMotion,
widthClassName,
}: {
signal: React.RefObject<Signal>
snapshot: Signal
reduceMotion: boolean
widthClassName: string
}) {
const primary = React.useRef<SVGPathElement>(null)
const secondary = React.useRef<SVGPathElement>(null)
const level = React.useRef(0)
useAnimationFrame((time) => {
if (reduceMotion || !primary.current || !secondary.current) return
const t = time / 1000
const target = targetLevel(signal.current, t)
level.current += (target - level.current) * 0.14
const amplitude = 1 + level.current * 12
const speed = signal.current.state === "thinking" ? 2.4 : 6
primary.current.setAttribute("d", wavePath(amplitude, t * speed, 1.5))
secondary.current.setAttribute(
"d",
wavePath(amplitude * 0.6, -t * speed * 0.7 + 1.2, 2.2)
)
})
const staticAmplitude = 1 + targetLevel(snapshot, 0) * 12
return (
<div className={cn("flex h-full items-center justify-center", widthClassName)}>
<svg
viewBox="0 0 100 30"
preserveAspectRatio="none"
className="h-full w-full overflow-visible"
fill="none"
aria-hidden
>
<path
ref={secondary}
d={wavePath(staticAmplitude * 0.6, 1.2, 2.2)}
stroke="currentColor"
strokeOpacity={0.35}
strokeWidth={1.5}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
<path
ref={primary}
d={wavePath(staticAmplitude, 0, 1.5)}
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
</div>
)
}
export { AiVoiceVisualizer, aiVoiceVisualizerVariants }
属性 Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| variant | "bars" | "orb" | "wave" | "bars" | 可视化波形形态。bars 为多柱跳动均衡器,orb 为随电平缩放的圆环(思考时显示旋转弧线),wave 为两层相位相反的声浪。 |
| state | "idle" | "listening" | "thinking" | "speaking" | "idle" | 语音会话当前所处的生命周期状态。idle(静止/休眠)、listening(正在聆听用户说话)、thinking(AI 推理生成中)、speaking(AI 语音朗读中)。 |
| audioLevel | number | — | 实时归一化音频电平数值,取值范围在 0.0 ~ 1.0 之间。传入时驱动波形起伏幅度与圆环缩放,内部逐帧平滑插值,高频更新也不会产生跳变;未传入时根据状态生成自然的模拟起伏。 |
| barCount | number | 5 | 在 bars 柱状模式下的声波柱数量。 |
| size | "sm" | "md" | "lg" | "md" | 可视化组件的物理尺寸大小密度。 |
| className | string | — | 应用于外层容器的额外 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className / style | React.HTMLAttributes<HTMLDivElement> | — | 继承原生 <div> 容器属性,作为纯渲染视觉反馈组件,状态由外部 audio 流或业务逻辑统一调度驱动。 |
使用场景与设计规范
AiVoiceVisualizer 为**多模态语音交互(Voice Agent / Realtime API / STT / TTS)**提供灵动、优雅的视觉反馈:
- 4 种生命周期状态对应色彩与动效:
idle:低对比度灰色静止,表达“准备就绪但未激活”。listening:信息蓝(info)随电平跳动,表达“正在专注捕获用户声音”。thinking:前景色波峰依次流过(圆环模式显示旋转弧线),表达“正在理解上下文与调用工具”。speaking:前景色随音量振幅起伏,表达“AI 正在实时回传发音”。
- 性能:动画运行在帧循环中并直接写入样式,
audioLevel以 60Hz 更新也不会触发额外的 React 渲染开销。 - 对接 Web Audio API:推荐通过浏览器的
AudioContext与AnalyserNode获取麦克风或音频元素的getByteFrequencyData,经过归一化计算后传入audioLevel,达到毫秒级音画同步。
场景示例
实时全双工语音通话界面 (Orb 呼吸球)
类似 ChatGPT Voice Mode 或 Siri 的全屏沉浸式语音通话卡片,结合静音、状态切换与挂断控制:
Loading…
语音输入条与波形图谱 (Live Bar Spectrum)
嵌入在聊天输入框或录音控制条中的紧凑声波指示器,直观反映麦克风拾音信号强弱:
Loading…
无障碍与交互 Accessibility
- 状态语义声明:外层容器为
role="img",并按状态提供默认aria-label(如「正在聆听」),可通过传入aria-label覆盖;同时带有data-state与data-variant属性。 - 动效降级:开启
prefers-reduced-motion时帧循环停止,波形直接根据电平渲染静态形态。