语音对话系统技术解析 - 基于阿里云 Qwen-Omni 实时语音大模型
语音对话系统技术解析
基于阿里云 Qwen-Omni 实时语音大模型的 Voice-to-Voice 对话实现
一、系统架构概览
本项目实现了一个实时语音对话系统,用户可以通过浏览器与 AI 进行自然的语音交流。整个系统采用三段式架构,通过 WebSocket 长连接实现低延迟的双向通信。
↓ events + audio
🌐 浏览器 (Frontend)
负责音频采集、播放和 UI 交互
- 使用 ScriptProcessor 采集 PCM 音频
- AudioPlayer 队列播放 AI 回复
- 根据事件类型更新 UI 状态
🐍 Python Server
纯代理转发,不处理业务逻辑
- 接收前端音频,转发给阿里云
- 同一音频同时发给 Omni 和 ASR
- 将阿里云事件透传给前端
🧠 Qwen-Omni API
语音对话的核心智能
- 服务端 VAD 检测语音活动
- 理解用户意图并生成回复
- 流式返回语音和文字
📝 Qwen-ASR API
实时语音转写服务
- 边接收边转写,流式返回
- 用于在 UI 显示用户说的话
- 支持中文及多种方言
二、WebSocket 长连接机制
整个系统使用 3 条 WebSocket 长连接保持实时通信。相比 HTTP 短连接,WebSocket 避免了每次请求的握手开销,延迟可低至 10ms 级别,非常适合实时语音场景。
| 连接 | 起点 | 终点 | 协议 | 用途 |
|---|---|---|---|---|
| 连接 1 | 浏览器 | Python Server | ws:// | 音频上传、事件下发 |
| 连接 2 | Python Server | Qwen-Omni API | wss:// | 语音对话 |
| 连接 3 | Python Server | Qwen-ASR API | wss:// | 语音转写 |
连接地址
# Qwen-Omni 实时对话
wss://dashscope.aliyuncs.com/api-ws/v1/realtime?model=qwen3-omni-flash-realtime
# Qwen-ASR 实时转写
wss://dashscope.aliyuncs.com/api-ws/v1/realtime?model=qwen3-asr-flash-realtime
生命周期
| 事件 | 连接状态变化 |
|---|---|
| 用户点击「连接」 | 浏览器 → Server 建立 WS;Server → 阿里云建立 2 个 WSS |
| 用户说话 | 数据在已建立的长连接上传输,无需重新建立 |
| 用户点击「断开」 | 3 条连接全部关闭 |
为什么需要 Python Server 中转?
1. API Key 保护 - 密钥存放在服务端,不暴露给前端
2. 双路转发 - 同一份音频需要发给 Omni 和 ASR 两个服务
3. 跨域限制 - 浏览器直连阿里云可能存在 CORS 问题
三、音频格式与数据流
PCM 音频格式
PCM (Pulse Code Modulation) 是一种无损音频编码格式,直接记录声波的采样值,没有压缩损失。
📥 输入格式 (用户语音)
- 格式: PCM 16-bit
- 采样率: 16,000 Hz
- 声道: 单声道 (Mono)
- 每样本: 2 字节
- 取值范围: -32768 ~ +32767
📤 输出格式 (AI 回复)
- 格式: PCM 24-bit
- 采样率: 24,000 Hz
- 声道: 单声道 (Mono)
- 质量: 更高 (更细腻)
数据分块传输
浏览器持续录音,每采集到一定量的样本就打包发送,实现边录边发的流式传输:
| 参数 | 值 | 说明 |
|---|---|---|
| 缓冲区大小 | 4096 samples |
ScriptProcessor 每次处理的样本数 |
| 采样率 | 16000 Hz |
每秒采集 16000 个样本 |
| 发送间隔 | 256 ms |
4096 ÷ 16000 = 0.256 秒 |
| 原始大小 | 8 KB |
4096 × 2 bytes = 8192 bytes |
| Base64 后 | ~11 KB |
Base64 编码增加约 33% 体积 |
数据流量估算
| 说话时长 | 发送次数 | 上传数据量 |
|---|---|---|
| 1 秒 | ~4 次 | ~44 KB |
| 3 秒 | ~12 次 | ~130 KB |
| 1 分钟 | ~234 次 | ~2.5 MB |
JSON 消息格式
前端 → Server
{
"type": "audio",
"data": "AAD//wAA//8AAP//..." // Base64 编码的 PCM16 音频
}
Server → 阿里云
{
"event_id": "audio_140234567890",
"type": "input_audio_buffer.append", // 阿里云 API 要求的事件类型
"audio": "AAD//wAA//8AAP//..."
}
Server 只做格式转换 - 不解码、不处理音频内容,只是把 data 字段改成 audio,加上 event_id,然后透传。
四、VAD 语音活动检测
VAD (Voice Activity Detection) 是实现自然对话的关键技术,用于检测用户什么时候开始说话、什么时候说完。本项目使用服务端 VAD,所有检测逻辑都在阿里云完成。
工作原理
配置参数
# server.py 中的 VAD 配置
"turn_detection": {
"type": "server_vad", # 使用服务端 VAD
"threshold": 0.5, # 语音检测阈值
"silence_duration_ms": 800 # 静音多久算说完 (毫秒)
}
| 参数 | 默认值 | 说明 |
|---|---|---|
type |
server_vad |
服务端检测,前端不参与判断 |
threshold |
0.5 |
语音能量阈值,越低越灵敏 |
silence_duration_ms |
800 |
静音超过此时长则判定说完 |
打断机制
当用户在 AI 回复过程中再次说话,系统会自动打断 AI:
// index.html - 收到 speech_started 事件
case 'speech_started':
updateStatus('speaking', '正在聆听...');
audioPlayer.clear(); // 🔇 清空 AI 音频队列,实现打断
createUserMessage();
break;
打断的本质 - 不是真的"打断"阿里云的生成,而是前端停止播放已收到的音频。阿里云检测到新的语音后会自动停止之前的回复生成。
五、核心事件详解
WebSocket 通信基于事件驱动,不同的事件类型代表不同的状态变化。
客户端 → 服务端
| 事件类型 | 说明 | 触发时机 |
|---|---|---|
session.update |
更新会话配置 | 连接建立后立即发送 |
input_audio_buffer.append |
追加音频数据 | 每 256ms 发送一次 |
服务端 → 客户端 (Qwen-Omni)
| 事件类型 | 说明 | 前端处理 |
|---|---|---|
input_audio_buffer.speech_started |
检测到用户开始说话 | 清空音频队列、创建用户消息气泡 |
input_audio_buffer.speech_stopped |
检测到用户停止说话 | 状态改为"AI 思考中" |
response.audio.delta |
AI 回复音频增量 | 加入播放队列播放 |
response.audio_transcript.delta |
AI 回复文字增量 | 追加到 AI 消息气泡 |
response.done |
AI 回复结束 | 状态改为"已连接" |
服务端 → 客户端 (Qwen-ASR)
| 事件类型 | 说明 | 前端处理 |
|---|---|---|
conversation.item.input_audio_transcription.delta |
用户语音转写增量 | 实时更新用户消息气泡 |
conversation.item.input_audio_transcription.completed |
用户语音转写完成 | 用户消息气泡定稿 |
事件转发映射
Server 收到阿里云的事件后,简化字段名转发给前端:
| 阿里云事件 | → | 转发给前端 |
|---|---|---|
input_audio_buffer.speech_started |
→ | speech_started |
input_audio_buffer.speech_stopped |
→ | speech_stopped |
response.audio.delta |
→ | audio |
response.audio_transcript.delta |
→ | transcript |
response.done |
→ | response_done |
六、交互时序流程
以下是用户说话 3 秒的完整交互时序,展示了各个组件之间的协作过程。点击控制按钮可以逐步查看或自动播放。
七、关键代码解析
前端录音与发送
// index.html - 创建音频处理器
state.audioContext = new AudioContext({ sampleRate: 16000 });
state.processor = state.audioContext.createScriptProcessor(4096, 1, 1);
// 每 256ms 触发一次
state.processor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
// Float32 → Int16 (PCM16)
const pcmData = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
const s = Math.max(-1, Math.min(1, inputData[i]));
pcmData[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
// Base64 编码并发送
const base64 = btoa(String.fromCharCode(...new Uint8Array(pcmData.buffer)));
state.ws.send(JSON.stringify({ type: 'audio', data: base64 }));
};
服务端会话配置
# server.py - 配置 Qwen-Omni 会话
session_config = {
"type": "session.update",
"session": {
"modalities": ["text", "audio"],
"voice": "Cherry", # AI 声音:Cherry/Ethan/Serena
"input_audio_format": "pcm16",
"output_audio_format": "pcm24",
"instructions": "你是一个友好的AI助手...",
"turn_detection": {
"type": "server_vad",
"threshold": 0.5,
"silence_duration_ms": 800
}
}
}
await self.qwen_ws.send(json.dumps(session_config))
服务端转发逻辑
# server.py - 收到前端音频,同时转发给两个 API
async def forward_to_qwen(self, audio_b64):
event = {
"event_id": f"audio_{id(audio_b64)}",
"type": "input_audio_buffer.append",
"audio": audio_b64 # 直接透传,不处理
}
# 发给 Qwen-Omni
await self.qwen_ws.send(json.dumps(event))
# 同时发给 ASR
if self.asr_ws:
await self.asr_ws.send(json.dumps(asr_event))
异步监听响应
# server.py - 发送和接收是两个独立的协程
# 启动监听任务 (不阻塞发送)
listen_task = asyncio.create_task(session.listen_qwen_responses())
asr_task = asyncio.create_task(session.listen_asr_responses())
# 监听 Qwen 响应
async def listen_qwen_responses(self):
async for message in self.qwen_ws: # 持续等待消息
event = json.loads(message)
event_type = event.get("type")
if event_type == "response.audio.delta":
# 透传给前端
await self.client_ws.send_json({
"type": "audio",
"data": event.get("delta")
})