返回博客列表
技术教程2026-03-2919 分钟阅读

PWA语音消息端到端处理:从录音到ASR转录的异步管道

从录音、上传到 ASR 转录:一条全异步的语音消息处理管道。

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 中,以音频文件形式传递
  • 需要桌面端下载并转录为文本后才能处理

🎤 核心模块一:纯语音输入检测

为什么需要检测?

不是所有带附件的消息都是纯语音。需要区分:

场景contentattachments类型
纯语音[语音消息]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 转录
桌面端 → PWAHTTP 上传 + WebSocket 信令send_voice Action、格式校验

双引擎 ASR 对比

维度GLM-ASRWhisper
运行位置云端本地
准确率高(商业级)中等(取决于模型)
延迟1-3 秒3-10 秒(CPU)
离线支持
配额限制有(API 调用限制)
格式支持wav/mp3/webm/m4awav(其他需转换)

字数统计: 约 4,600 字
阅读时间: 约 12 分钟
代码行数: 约 280 行


🎉 WeClaw 技术博客系列已完成 44 篇!

本轮迭代(v3.0.0 ~ v3.1.0)共产出 4 篇深度技术博客:

  • 第 41 篇:WebSocket + HTTP 混合协议设计
  • 第 42 篇:Agent 工具注册全链路标准化
  • 第 43 篇:双重认证与 Token 自动刷新
  • 第 44 篇:PWA 语音消息端到端处理管道