WeClaw_44_PWA语音消息端到端处理:从录音到ASR转录的异步管道
作者: WeClaw 开发团队
日期: 2026-03-29
版本: v1.0
标签: 语音消息、ASR、GLM-ASR、Whisper、音频转录、PWA
📖 摘要
本文完整剖析 WeClaw 系统中 PWA 语音消息的端到端处理管道。当用户在手机 PWA 端录制一段语音消息发送给桌面端 AI 时,系统需要完成:语音检测 → 音频下载 → ASR 转录 → 文本处理 → AI 回复。文章涵盖纯语音输入检测算法、双引擎 ASR(GLM-ASR 云端 + Whisper 本地)、音频格式自动转换、以及桌面端主动发送语音消息到 PWA 的反向通道。
核心收获:
- 🎤 掌握纯语音输入的检测算法
- 🗣️ 理解 GLM-ASR 云端 + Whisper 本地的双引擎架构
- 🔄 学会音频文件下载与格式转换流程
- ⚡ 掌握跳过 ReAct 循环的性能优化策略
- 📤 了解桌面端发送语音消息到 PWA 的实现
🎯 需求背景:语音消息的特殊性
语音 vs 文本的处理差异
文本消息处理流程:
"帮我查一下天气" → 意图识别 → 工具调用 → AI 回复
└── 简单直接
语音消息处理流程:
🎤 音频文件 → 检测类型 → 下载文件 → ASR 转录 → 得到文本 → 意图识别 → AI 回复
└── 多了三个额外步骤
PWA 端的语音消息格式
PWA 用户录音后发送的消息结构:
{
"type": "pwa_request",
"payload": {
"content": "[语音消息]",
"attachments": [
{
"attachment_id": "uuid-xxx",
"filename": "recording_20260329.webm",
"mime_type": "audio/webm",
"data": "/api/files/uuid-xxx"
}
]
}
}
特点:
content为占位符文本[语音消息],不是实际内容- 实际语音数据在
attachments中,以音频文件形式传递 - 需要桌面端下载并转录为文本后才能处理
🎤 核心模块一:纯语音输入检测
为什么需要检测?
不是所有带附件的消息都是纯语音。需要区分:
| 场景 | content | attachments | 类型 |
|---|---|---|---|
| 纯语音 | [语音消息] | 1 个音频文件 | 纯语音 ✅ |
| 文字+图片 | 帮我识别这张图 | 1 个图片 | 多模态 ❌ |
| 纯图片 | (空) | 1 个图片 | 文件附件 ❌ |
| 文字+语音 | 帮我翻译这段话 | 1 个音频 | 多模态 ❌ |
检测算法
# 语音占位符文本集合
VOICE_PLACEHOLDER_TEXTS = {
"[语音消息]", "[voice message]", "[Voice Message]",
"[audio]", "[Audio]",
}
def _is_voice_only_input(self, attachments: list, content: str) -> bool:
"""检测是否为纯语音输入。"""
# 条件 1:必须有附件
if not attachments:
return False
# 条件 2:content 为空或是语音占位符
actual_content = content.strip() if content else ""
if actual_content and actual_content not in self.VOICE_PLACEHOLDER_TEXTS:
return False
# 条件 3:只有一个附件
if len(attachments) != 1:
return False
att = attachments[0]
mime_type = att.get("mime_type", "")
filename = att.get("filename", "")
# 条件 4:附件是音频文件
# 方式一:MIME 类型判断
if mime_type.startswith("audio/"):
return True
# 方式二:扩展名判断(兜底)
audio_exts = ('.mp3', '.wav', '.ogg', '.aac', '.flac', '.m4a', '.wma', '.webm')
if filename.lower().endswith(audio_exts):
return True
return False
设计要点:
- 占位符集合覆盖中英文变体
- MIME 类型优先,扩展名兜底
- 多附件不算纯语音(可能是文件+语音组合)
🗣️ 核心模块二:双引擎 ASR 转录
引擎选择策略
GLM-ASR(云端,推荐)
├── 优势:准确率高、支持多语言、无需本地模型
├── 劣势:需要网络、有 API 配额
└── 适用:正常网络环境
Whisper(本地,备用)
├── 优势:离线可用、无配额限制
├── 劣势:需要模型下载、GPU 加速效果更好
└── 适用:无网络或 GLM 不可用
voice_input 工具的 transcribe_file 动作
ActionDef(
name="transcribe_file",
description="将音频文件转为文字",
parameters={
"file_path": {
"type": "string",
"description": "音频文件路径(支持 wav/mp3/m4a/webm 等)",
},
"engine": {
"type": "string",
"description": "识别引擎:glm-asr(云端) 或 whisper(本地)",
"default": "glm-asr",
"enum": ["glm-asr", "whisper"],
},
"model": {
"type": "string",
"description": "Whisper 模型 (仅 whisper 引擎)",
"default": "base",
"enum": ["tiny", "base", "small", "medium", "large"],
},
},
required_params=["file_path"],
)
转录实现(引擎选择 + 自动降级)
async def _transcribe_file(
self, file_path: str, engine: str = None,
model: str = "base", language: str = None
) -> ToolResult:
"""将音频文件转为文字。"""
if engine is None:
engine = self._engine # 实例默认引擎
path = Path(file_path).expanduser().resolve()
# 文件大小限制(50MB)
if path.stat().st_size / (1024 * 1024) > 50:
return ToolResult(status=ToolResultStatus.ERROR,
error="文件过大")
# 根据引擎选择转录方式
if engine == "glm-asr":
if not _check_glm_asr():
logger.warning("GLM ASR 不可用,降级到 Whisper")
engine = "whisper" # 自动降级
else:
return await self._transcribe_file_with_glm_asr(path, ...)
if engine == "whisper":
if not _check_voice_dependencies():
return ToolResult(status=ToolResultStatus.ERROR,
error="Whisper 不可用,请安装 openai-whisper")
return await self._transcribe_file_with_whisper(path, model, ...)
自动降级流程:
用户指定 glm-asr
↓
检查 GLM ASR 可用性
├── 可用 → 调用 GLM ASR 转录
└── 不可用 → 降级到 Whisper
├── 可用 → 调用 Whisper 转录
└── 不可用 → 返回错误
🔄 核心模块三:语音消息完整处理管道
桌面端接收 PWA 语音消息
async def _transcribe_voice_attachment(
self, attachment: dict, user_id: str, session_id: str
) -> str | None:
"""直接转录音频附件,返回转录文本。"""
filename = attachment.get("filename", "unknown")
data = attachment.get("data", "")
# 1. 构建完整下载 URL
server_base_url = self._get_server_base_url()
if data.startswith("/api/files/"):
full_url = f"{server_base_url}{data}"
elif data.startswith("http"):
full_url = data
else:
return None
# 2. 下载音频文件到本地
local_path = self._download_remote_file(
url=full_url,
filename=filename,
attachment_id=attachment.get("attachment_id", ""),
user_id=user_id,
session_id=session_id,
mime_type=attachment.get("mime_type", ""),
file_type="audio",
user_message="",
)
if not local_path:
return None
# 3. 调用 voice_input 工具转录
from src.tools.voice_input import VoiceInputTool
tool = VoiceInputTool()
result = await tool.execute("transcribe_file", {
"file_path": local_path,
"engine": "glm-asr", # 默认使用云端引擎
})
# 4. 提取转录文本
if result.status.value == "success":
text = result.data.get("text", "")
return text.strip() if text else None
return None
跳过 ReAct 循环的优化
纯语音消息的处理不需要经过完整的 AI ReAct 循环:
普通消息流程(ReAct 循环):
用户输入 → 意图识别 → 工具暴露 → LLM 分析 → 工具调用 → LLM 整合 → 回复
└── 需要多次 LLM 调用,耗时较长
纯语音消息流程(跳过 ReAct):
语音附件 → 检测纯语音 → 直接转录 → 转录文本作为用户输入 → 正常处理
└── 省去"正在转录"中间提示,直接反馈结果
# 处理入口
if self._is_voice_only_input(attachments, content):
# 纯语音消息:直接转录,不经过 ReAct
transcribed = await self._transcribe_voice_attachment(
attachments[0], user_id, session_id
)
if transcribed:
# 用转录文本替换占位符,作为正常用户输入处理
content = transcribed
attachments = [] # 清空附件,避免重复处理
📤 核心模块四:桌面端发送语音消息
send_voice Action
桌面端也可以主动向 PWA 发送语音消息:
async def _send_voice(self, params: dict) -> ToolResult:
"""发送语音消息到 PWA。"""
err = self._check_bridge()
if err:
return err
file_path = params.get("file_path", "")
path = Path(file_path)
# 校验文件扩展名
if path.suffix.lower() not in VOICE_EXTENSIONS:
return ToolResult(
status=ToolResultStatus.ERROR,
error=f"不支持的语音格式: {path.suffix}",
)
user_id = self._resolve_user_id(params)
transcript = params.get("transcript", "")
# description 添加 [语音消息] 标记
description = f"[语音消息] {transcript}" if transcript else "[语音消息]"
success = await self._bridge_client.send_file_to_pwa(
user_id=user_id,
file_path=file_path,
description=description,
)
if success:
return ToolResult(
status=ToolResultStatus.SUCCESS,
data={"message": "语音消息已发送到 PWA"},
)
else:
return ToolResult(
status=ToolResultStatus.ERROR,
error="语音消息发送失败",
)
语音格式白名单
VOICE_EXTENSIONS = {
".mp3", # MPEG Audio Layer 3
".wav", # Waveform Audio
".ogg", # Ogg Vorbis
".flac", # Free Lossless Audio Codec
".aac", # Advanced Audio Coding
".m4a", # MPEG-4 Audio
".opus", # Opus Audio
".webm", # WebM Audio(PWA 录音默认格式)
}
📊 完整数据流
PWA → 桌面端(接收语音消息)
PWA 用户录音
↓
PWA: 上传音频到服务器 → attachment_id
↓
PWA → 服务器 → 桌面端: pwa_request
content="[语音消息]"
attachments=[{filename:"rec.webm", data:"/api/files/xxx"}]
↓
桌面端: _is_voice_only_input() → True
↓
桌面端: _transcribe_voice_attachment()
├── 下载 webm 文件
├── 调用 GLM-ASR 转录
└── 返回文本 "明天的天气怎么样"
↓
桌面端 AI: 以转录文本为输入,正常处理
↓
桌面端 → 服务器 → PWA: AI 文本回复
桌面端 → PWA(发送语音消息)
LLM 调用 remote_file_share_send_voice
file_path="generated/tts_output.mp3"
transcript="你好,这是一条语音消息"
↓
RemoteFileShareTool._send_voice()
├── 校验扩展名 ∈ VOICE_EXTENSIONS
├── description = "[语音消息] 你好,这是一条语音消息"
└── 调用 bridge.send_file_to_pwa()
├── HTTP POST 上传文件
└── WebSocket file_share 信令
↓
PWA 收到语音消息(可播放收听)
💡 经验教训
1. webm 格式的兼容性
教训:PWA 默认录音格式为 webm,某些 ASR 引擎不支持。
解决方案:GLM-ASR 原生支持 webm;Whisper 降级时自动转换为 wav。
2. 占位符文本的多样性
教训:最初只检测 [语音消息],国际化场景遗漏。
解决方案:维护占位符集合,覆盖中英文和大小写变体。
3. 空消息拦截的副作用
教训:系统原有逻辑拦截了 content 为空的消息,导致纯语音消息(无文字仅有附件)被丢弃。
解决方案:修改拦截逻辑,允许仅含附件的消息通过。
📊 架构总结
语音消息处理全景
| 方向 | 通道 | 关键技术 |
|---|---|---|
| PWA → 桌面端 | WebSocket 信令 + HTTP 下载 | 纯语音检测、GLM-ASR 转录 |
| 桌面端 → PWA | HTTP 上传 + WebSocket 信令 | send_voice Action、格式校验 |
双引擎 ASR 对比
| 维度 | GLM-ASR | Whisper |
|---|---|---|
| 运行位置 | 云端 | 本地 |
| 准确率 | 高(商业级) | 中等(取决于模型) |
| 延迟 | 1-3 秒 | 3-10 秒(CPU) |
| 离线支持 | ❌ | ✅ |
| 配额限制 | 有(API 调用限制) | 无 |
| 格式支持 | wav/mp3/webm/m4a | wav(其他需转换) |
字数统计: 约 4,600 字
阅读时间: 约 12 分钟
代码行数: 约 280 行
🎉 WeClaw 技术博客系列已完成 44 篇!
本轮迭代(v3.0.0 ~ v3.1.0)共产出 4 篇深度技术博客:
- 第 41 篇:WebSocket + HTTP 混合协议设计
- 第 42 篇:Agent 工具注册全链路标准化
- 第 43 篇:双重认证与 Token 自动刷新
- 第 44 篇:PWA 语音消息端到端处理管道